863 lines
45 KiB
HTML
863 lines
45 KiB
HTML
|
||
<style>
|
||
/* ================= 基礎排版與重設 ================= */
|
||
.print-preview-app {
|
||
font-family: "Microsoft JhengHei", DFKai-sb, sans-serif;
|
||
margin: 0;
|
||
background: #f8f9fa;
|
||
display: flex;
|
||
gap: 24px;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
padding: 24px;
|
||
}
|
||
|
||
#preview-container {
|
||
display: flex;
|
||
flex-grow: 1;
|
||
background: #e2e8f0;
|
||
padding: 32px;
|
||
overflow: auto;
|
||
position: relative;
|
||
border-radius: 12px;
|
||
justify-content: center;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
#paper-preview {
|
||
background: #fff;
|
||
padding: 20mm;
|
||
box-sizing: border-box;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||
width: 210mm;
|
||
height: 297mm;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
transform-origin: top center;
|
||
}
|
||
|
||
.dual-editor-wrapper {
|
||
display: flex;
|
||
flex-direction: row-reverse;
|
||
width: 100%;
|
||
height: 100%;
|
||
gap: 40px;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.editor {
|
||
display: flex;
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
border: 1px dashed #cbd5e1;
|
||
overflow: hidden;
|
||
width: 105mm;
|
||
height: 297mm;
|
||
position: relative;
|
||
background-size: 100% 100%;
|
||
}
|
||
|
||
.tablet-element {
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
transition: border 0.2s;
|
||
}
|
||
|
||
.tablet-element:hover {
|
||
border: 1px dashed #94a3b8;
|
||
}
|
||
|
||
/* ================= 浮動選單樣式 ================= */
|
||
#line-floating-menu {
|
||
position: fixed;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
backdrop-filter: blur(8px);
|
||
border: 1px solid #e2e8f0;
|
||
padding: 16px;
|
||
border-radius: 10px;
|
||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||
z-index: 99999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
width: 30vw;
|
||
height: 80vh;
|
||
left: 50px;
|
||
top: 50px;
|
||
overflow: auto;
|
||
}
|
||
|
||
#line-floating-menu h4 {
|
||
margin: 0;
|
||
color: #4a5568;
|
||
font-size: 12px;
|
||
cursor: move;
|
||
background: #f8fafc;
|
||
padding: 8px;
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
user-select: none;
|
||
border: 1px solid #edf2f7;
|
||
}
|
||
|
||
.control-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #4a5568;
|
||
}
|
||
|
||
.adjust-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.adjust-row button {
|
||
padding: 4px 12px;
|
||
border: 1px solid #e2e8f0;
|
||
background: #fff;
|
||
color: #4a5568;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.adjust-row button:hover {
|
||
background: #f1f5f9;
|
||
}
|
||
|
||
.adjust-row input[type="range"] {
|
||
flex-grow: 1;
|
||
padding: 0;
|
||
}
|
||
|
||
/* ================= 名單 UI 與排版樣式 ================= */
|
||
.item-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
max-height: 120px;
|
||
overflow-y: auto;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 6px;
|
||
background: #fff;
|
||
}
|
||
|
||
.item-list li {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 4px 8px;
|
||
border-bottom: 1px solid #edf2f7;
|
||
font-size: 13px;
|
||
color: #4a5568;
|
||
}
|
||
|
||
.list-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.list-actions button, .item-list button {
|
||
padding: 2px 6px;
|
||
background: #f8fafc;
|
||
border: 1px solid #cbd5e1;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
color: #4a5568;
|
||
line-height: 1;
|
||
}
|
||
|
||
.list-actions button:hover, .item-list button:hover {
|
||
background: #e2e8f0;
|
||
}
|
||
|
||
/* 直書排版容器 */
|
||
.vertical-text {
|
||
writing-mode: vertical-rl;
|
||
text-orientation: mixed;
|
||
}
|
||
|
||
.nameList, .liveList {
|
||
writing-mode: vertical-rl;
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
align-content: center;
|
||
align-items: center;
|
||
margin: auto;
|
||
border: 0px solid transparent;
|
||
}
|
||
|
||
.nameSpan, .liveSpan {
|
||
display: block;
|
||
line-height: 1.1;
|
||
text-align: justify;
|
||
text-align-last: justify;
|
||
text-justify: inter-character;
|
||
word-break: break-all;
|
||
white-space: pre-line;
|
||
}
|
||
|
||
/* 列印設定 */
|
||
@media print {
|
||
.print-preview-app {
|
||
padding: 0;
|
||
height: auto;
|
||
overflow: visible;
|
||
}
|
||
|
||
#line-floating-menu {
|
||
display: !important;
|
||
}
|
||
|
||
#preview-container {
|
||
background: #fff;
|
||
padding: 0;
|
||
margin: 0;
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
}
|
||
|
||
#paper-preview {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
box-shadow: none;
|
||
display: block !important;
|
||
padding-top: 30mm !important;
|
||
padding-right: 30mm !important;
|
||
padding-bottom: 0 !important;
|
||
padding-left: 0 !important;
|
||
border: none;
|
||
}
|
||
|
||
.editor {
|
||
height: auto !important;
|
||
max-height: 100% !important;
|
||
border: none;
|
||
}
|
||
|
||
.no-print {
|
||
display: none !important;
|
||
}
|
||
}
|
||
</style>
|
||
<!-- 頂端控制列 -->
|
||
<div class="row mt-2 no-print" style="padding: 0 24px; background: white; z-index: 10;">
|
||
<div class="col-3">
|
||
範本設定:
|
||
<select id="templateSel" class="form-select">
|
||
<option value="">請選擇範本</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-3">
|
||
範本名稱:
|
||
<input type="text" id="template_name_txt" class="form-control" placeholder="" />
|
||
</div>
|
||
<div class="col-2 d-flex align-items-end gap-2">
|
||
<button class="btn btn-outline-secondary" id="btn_new_template">新增範本</button>
|
||
<button class="btn btn-primary" id="btn_save">儲存範本</button>
|
||
</div>
|
||
<div class="col-4">
|
||
<div style="display: flex; align-items: center; padding: 8px; background-color: #f5f5f5; border-radius: 4px;">
|
||
<span style="margin-right: 8px; cursor:pointer;" id="zoomOut">➖</span>
|
||
<input type="range" id="scaleSlider" min="0.0" max="1.0" step="0.05" value="0.5" style="flex-grow: 1; cursor: pointer;">
|
||
<span style="margin-left: 8px; cursor:pointer;" id="zoomIn">➕</span>
|
||
<span id="scaleSize" style="margin-left: 12px; min-width: 45px; font-weight: bold; font-size: 14px; text-align: right;">50%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="print-preview-app">
|
||
<div id="preview-container">
|
||
<div id="paper-preview" style="transform: scale(0.5);">
|
||
<div class="dual-editor-wrapper">
|
||
<div id="editor-right" class="editor"></div>
|
||
<div id="editor-left" class="editor printArea"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 浮動設定面板 -->
|
||
<div id="line-floating-menu" class="no-print" style="display: ;">
|
||
<h4 id="menu-header">✥ 拖拉此處移動面板</h4>
|
||
<div class="control-group">
|
||
<label>版型設定</label>
|
||
<div class="adjust-row">
|
||
<select class="styleSelector" style="width: 100%; padding: 6px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; outline: none; background-color: #f8fafc;">
|
||
<option value="">請選擇版型</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<hr style="margin: 15px 0;">
|
||
|
||
<div class="control-group">
|
||
<label style="color:#d63031; font-weight:bold;" id="current-edit-label">目前編輯:請點擊牌位區塊</label>
|
||
</div>
|
||
|
||
<div class="control-group" style="height:50%;">
|
||
<label>已選</label>
|
||
<div class="adjust-row">
|
||
<input type="text" id="custom_txt" placeholder="請輸入名稱" style="padding:4px; font-size:12px; width: 100%;">
|
||
<button id="btn_add_custom" style="width: 32px; padding: 4px;">+</button>
|
||
</div>
|
||
<ul id="selectedItem" class="item-list"></ul>
|
||
</div>
|
||
|
||
<div class="control-group" style="height:50%;">
|
||
<label>可選</label>
|
||
<input type="text" id="filter_txt" placeholder="篩選條件..." style="padding:4px; font-size:12px;">
|
||
<ul id="canselectItem" class="item-list"></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const designer = {
|
||
zoomScale: 0.5,
|
||
familyMembers: [],
|
||
allStyle: [],
|
||
papers: [],
|
||
follower_id: "",
|
||
|
||
// 動態名單資料
|
||
properTitle: [],
|
||
leftProperTitle: [],
|
||
rightProperTitle: [],
|
||
alive: [],
|
||
address: "",
|
||
shuwenList: [],
|
||
|
||
selectedElement: null,
|
||
selectStyle: "",
|
||
tabletTempNameList: [],
|
||
selectedTemplate: {},
|
||
bg: [
|
||
{ name: "超薦1", path: "../print/html/tablet-1_new.svg" },
|
||
{ name: "超薦2", path: "../print/html/tablet-1B_new.svg" },
|
||
{ name: "消災1", path: "../print/html/tablet-2.svg" },
|
||
{ name: "消災2", path: "../print/html/tablet-2B.svg" },
|
||
{ name: "消災3", path: "../print/html/tablet-2C.svg" },
|
||
{ name: "消災4", path: "../print/html/tablet-2D.svg" }
|
||
],
|
||
|
||
async init() {
|
||
this.follower_id = typeof VueApp !== 'undefined' ? VueApp.follower_id : "";
|
||
this.bindZoom();
|
||
this.bindFloatingMenu();
|
||
this.bindListEvents();
|
||
|
||
await Promise.all([
|
||
this.getTabletStyles(),
|
||
this.getFamilies(),
|
||
this.getTemplateNameList(),
|
||
this.getData()
|
||
]);
|
||
},
|
||
|
||
// ================= 1. API 資料撈取 =================
|
||
async getFamilies() {
|
||
let res = await axios.post('/api/follower/GetFollowerFamilies', { num: this.follower_id });
|
||
if (res.status === 200 && res.data.list) this.familyMembers = res.data.list;
|
||
},
|
||
async getTabletStyles() {
|
||
let res = await axios.post('/api/tablet/GetTabletStyleList', {});
|
||
if (res.status === 200 && res.data.list) {
|
||
this.allStyle = res.data.list.filter(x => x.styleID !== "000001");
|
||
$('.styleSelector').empty().append('<option value="">請選擇版型</option>');
|
||
this.allStyle.forEach(x => $('.styleSelector').append(`<option value="${x.styleID}">${x.name}</option>`));
|
||
this.papers = res.data.papers || [];
|
||
}
|
||
},
|
||
async getData() {
|
||
this.properTitle = []; this.leftProperTitle = []; this.rightProperTitle = []; this.alive = [];
|
||
// 參數建議統一為 num 或 follower_num,依您後端 API 實際定義為主
|
||
let res = await axios.post('/api/follower/GetTabletTemplateList', { num: this.follower_id });
|
||
|
||
// 🌟 修正:確保 res.data.list 存在,再對其進行 forEach
|
||
if (res.status === 200 && res.data.list) {
|
||
res.data.list.forEach(x => {
|
||
if (x.style_id) {
|
||
this.selectStyle = x.style_id;
|
||
$(".styleSelector").val(x.style_id);
|
||
}
|
||
if (x.tablet) {
|
||
let tablet = JSON.parse(x.tablet);
|
||
if (tablet.mid_items) this.properTitle = tablet.mid_items;
|
||
if (tablet.left_items) this.alive = tablet.left_items;
|
||
if (tablet.mid_left) this.leftProperTitle = tablet.mid_left;
|
||
if (tablet.mid_right) this.rightProperTitle = tablet.mid_right;
|
||
this.address = tablet.addr_items || x.address || "";
|
||
}
|
||
});
|
||
this.renderEditorLeft();
|
||
this.renderEditorRight();
|
||
}
|
||
},
|
||
async getTemplateNameList() {
|
||
$("#templateSel").empty().append('<option value="">請選擇範本</option>');
|
||
this.tabletTempNameList = [];
|
||
let res = await axios.post('/api/follower/GetTabletTemplateList', { num: this.follower_id });
|
||
if (res.status === 200 && res.data.list) {
|
||
this.tabletTempNameList = res.data.list;
|
||
this.tabletTempNameList.forEach(x => $('#templateSel').append(`<option value="${x.num}">${x.name}</option>`));
|
||
}
|
||
},
|
||
|
||
// ================= 2. 動態渲染排版 =================
|
||
// 🌟 輔助函式:統一萃取名稱,避免重複寫 if-else
|
||
getExtractedNames(arr) {
|
||
if (!arr || arr.length === 0) return ["請選擇"];
|
||
return arr.map(x => typeof x === 'string' ? x : x.fam_name);
|
||
},
|
||
|
||
// 🌟 輔助函式:統一根據 elementID 產生對應 HTML,解決 renderEditorLeft 與 rerenderPart 的冗餘
|
||
generateElementHtml(d) {
|
||
let pTitleNames = this.getExtractedNames(this.properTitle);
|
||
let aliveNames = this.getExtractedNames(this.alive);
|
||
let leftNames = this.getExtractedNames(this.leftProperTitle);
|
||
let rightNames = this.getExtractedNames(this.rightProperTitle);
|
||
let addressStr = typeof this.address === 'string' ? this.address : (this.address ? this.address.fam_name : "");
|
||
if (!addressStr) addressStr = "請選擇";
|
||
|
||
if (d.elementID === 'title1') return this.buildNameList(pTitleNames, d);
|
||
if (d.elementID === 'titletriangle') return this.buildRosterList(pTitleNames, d);
|
||
if (d.elementID === 'combined' || d.elementID === 'tricombined') return this.buildCombinedList(pTitleNames, d);
|
||
if (d.elementID === 'alive') return this.buildLiveList(aliveNames, d);
|
||
if (d.elementID === 'lefttitle') return this.buildNormalWithSpace(leftNames, d, 'lefttitle');
|
||
if (d.elementID === 'righttitle') return this.buildNormalWithSpace(rightNames, d, 'righttitle');
|
||
if (d.elementID === 'address') return `<div class='address' style='width:${d.width}px; height:${d.height}px; font-size:${d.fontSize}px; font-family:${d.fontFamily};'>${addressStr}</div>`;
|
||
return "";
|
||
},
|
||
|
||
renderEditorLeft() {
|
||
$('#editor-left').empty();
|
||
let style = this.allStyle.find(y => y.styleID == this.selectStyle);
|
||
if (!style) return;
|
||
|
||
let size = this.papers.find(y => y.paperID == style.paperSize);
|
||
let printSize = this.papers.find(y => y.paperID == style.printSize);
|
||
let img = this.bg.find(y => y.name == style.backendImg);
|
||
|
||
$('#paper-preview').css({ width: (Number(printSize.width) * 2) + 'mm', height: (Number(printSize.height) + 20) + 'mm' });
|
||
$('#editor-left').css({ width: Number(size.width) + 'mm', height: Number(size.height) + 'mm', backgroundImage: `url(${img.path})` });
|
||
$('#editor-right').css({ width: '297mm', height: Number(size.height) + 'mm' });
|
||
|
||
(style.details || []).forEach(d => {
|
||
if (d.isActive !== "hidden") {
|
||
let html = this.generateElementHtml(d);
|
||
if (html) {
|
||
let wrapper = $(`<div class="tablet-element vertical-text"></div>`).css({
|
||
position: 'absolute', left: d.startX + 'mm', top: d.startY + 'mm',
|
||
fontFamily: d.fontFamily, fontWeight: 'bold', zIndex: 9999, visibility: d.isActive
|
||
}).html(html);
|
||
$('#editor-left').append(wrapper);
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
rerenderPart() {
|
||
if (!this.selectedElement) return;
|
||
let target = this.selectedElement.children().first();
|
||
let style = this.allStyle.find(y => y.styleID == this.selectStyle);
|
||
if (!style) return;
|
||
|
||
let d = (style.details || []).find(y => (
|
||
(target.hasClass("nameList") && y.elementID == "title1") ||
|
||
(target.hasClass("rosterList") && y.elementID == "titletriangle") ||
|
||
(target.hasClass("ancestor-wrapper") && (y.elementID == "combined" || y.elementID == "tricombined")) ||
|
||
(target.hasClass("liveList") && y.elementID == "alive") ||
|
||
(target.hasClass("lefttitle") && y.elementID == "lefttitle") ||
|
||
(target.hasClass("righttitle") && y.elementID == "righttitle") ||
|
||
(target.hasClass("address") && y.elementID == "address")
|
||
));
|
||
if (!d) return;
|
||
|
||
this.selectedElement.empty();
|
||
this.selectedElement.append(this.generateElementHtml(d));
|
||
},
|
||
|
||
renderEditorRight() {
|
||
let $editorRight = $('#editor-right').empty();
|
||
$editorRight.css({ "writing-mode": "vertical-rl", "text-orientation": "mixed", "padding": "40px 20px 20px 20px", "display": "block" });
|
||
|
||
this.shuwenList.forEach((name, idx) => {
|
||
if (!name || name === "請選擇") return;
|
||
let $div = $('<div></div>').css({
|
||
"font-size": "16pt", "font-family": "DFKai-sb", "line-height": "1.2", "letter-spacing": "2px",
|
||
"white-space": "pre-line", "margin-left": "25px", "position": "relative"
|
||
}).text(name);
|
||
|
||
let $controls = $(`
|
||
<div class="no-print" style="position: absolute; top: -30px; left: 50%; transform: translateX(-50%); writing-mode: horizontal-tb; display: flex; gap: 4px; z-index: 10;">
|
||
<button class="btn-shuwen-up" data-idx="${idx}" style="padding: 0 4px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #fff;" title="往右移">↑</button>
|
||
<button class="btn-shuwen-down" data-idx="${idx}" style="padding: 0 4px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #fff;" title="往左移">↓</button>
|
||
</div>
|
||
`);
|
||
$div.append($controls);
|
||
$editorRight.append($div);
|
||
});
|
||
|
||
$editorRight.off('click', '.btn-shuwen-up').on('click', '.btn-shuwen-up', (e) => {
|
||
e.preventDefault(); let idx = $(e.currentTarget).data('idx');
|
||
if (idx > 0) { [this.shuwenList[idx - 1], this.shuwenList[idx]] = [this.shuwenList[idx], this.shuwenList[idx - 1]]; this.renderEditorRight(); }
|
||
});
|
||
|
||
$editorRight.off('click', '.btn-shuwen-down').on('click', '.btn-shuwen-down', (e) => {
|
||
e.preventDefault(); let idx = $(e.currentTarget).data('idx');
|
||
if (idx < this.shuwenList.length - 1) { [this.shuwenList[idx], this.shuwenList[idx + 1]] = [this.shuwenList[idx + 1], this.shuwenList[idx]]; this.renderEditorRight(); }
|
||
});
|
||
},
|
||
|
||
// ================= 3. 排版引擎 =================
|
||
buildNameList(names, d) {
|
||
let w = parseFloat(d.width), h = parseFloat(d.height), th = parseFloat(d.textHeight) || h;
|
||
let v_gap = parseFloat(d.textSpan) || 0, h_gap = 5;
|
||
let shrinkLimit = (h < 500) ? 8 : ((h < 300) ? 12 : 6);
|
||
let long_s = parseFloat(d.fontSize), short_s = parseFloat(d.fontSize);
|
||
|
||
let testNames = [...names];
|
||
|
||
if (h<500&&testNames.length === 1 && testNames[0].includes("暨無始劫以來累世冤親債主")) testNames[0] = testNames[0].split("暨")[0] + "暨";
|
||
|
||
let items = testNames.map((name, i) => {
|
||
let l = name.length, isShort = (testNames.length > 1) && ((l * 40) <= (th + (v_gap / 2)));
|
||
return { name: names[i], l: l, isShort: isShort, isMulti: false, size: 0 };
|
||
});
|
||
|
||
let max_short_effective = shrinkLimit, min_long_limit = parseFloat(d.fontSize);
|
||
items.forEach(item => {
|
||
if (item.isShort) { let ms = Math.floor(th / (item.l * 1.333)); if (ms > max_short_effective) max_short_effective = ms; }
|
||
else { let mh = Math.floor((h - 10) / (item.l * 1.333)); if (mh < min_long_limit) min_long_limit = mh; }
|
||
});
|
||
|
||
if (short_s > max_short_effective) short_s = max_short_effective;
|
||
if (long_s > min_long_limit) long_s = min_long_limit;
|
||
let sizes = [], shrink_turn = 'long';
|
||
|
||
while (true) {
|
||
let totalWidth = 0, current_col_h = 0, current_col_w = 0, cols_width = [], any_wrapped = false;
|
||
for (let i = 0; i < items.length; i++) {
|
||
let item = items[i], s = item.isShort ? short_s : long_s;
|
||
if (item.isShort) { let ms = Math.floor(th / (item.l * 1.333)); if (s > ms) s = ms; }
|
||
item.size = s; let size_px = s * 1.333;
|
||
|
||
if (item.isShort) {
|
||
let item_h = Math.max(th, item.l * size_px);
|
||
if (current_col_h === 0) { current_col_h = item_h; current_col_w = size_px; }
|
||
else if ((current_col_h + v_gap + item_h) <= h) { current_col_h += (v_gap + item_h); if (size_px > current_col_w) current_col_w = size_px; }
|
||
else { cols_width.push(current_col_w); current_col_h = item_h; current_col_w = size_px; }
|
||
} else {
|
||
if (current_col_h > 0) { cols_width.push(current_col_w); current_col_h = 0; current_col_w = 0; }
|
||
let item_w = size_px;
|
||
if ((item.l * size_px) > (h - 10)) {
|
||
item.isMulti = true; any_wrapped = true;
|
||
item_w = Math.ceil(item.l / Math.max(1, Math.floor((h - 10) / size_px))) * (size_px * 1.1);
|
||
} else { item.isMulti = false; }
|
||
cols_width.push(item_w);
|
||
}
|
||
}
|
||
if (current_col_h > 0) cols_width.push(current_col_w);
|
||
totalWidth = cols_width.reduce((a, b) => a + b, 0) + (cols_width.length > 1 ? (cols_width.length - 1) * h_gap : 0);
|
||
|
||
if (totalWidth <= w && (!any_wrapped || long_s === shrinkLimit)) {
|
||
sizes = items.map(x => { let fs = x.size; if (x.isShort) { let ms = Math.floor(th / (x.l * 1.333)); if (fs > ms) fs = ms; } return { name: x.name, size: fs, isMulti: x.isMulti, isShort: x.isShort }; });
|
||
break;
|
||
}
|
||
|
||
if (any_wrapped && long_s > shrinkLimit) long_s--;
|
||
else {
|
||
let hasLong = items.some(x => !x.isShort), hasShort = items.some(x => x.isShort);
|
||
if (hasLong && hasShort) { if (shrink_turn === 'long') { if (long_s > shrinkLimit) { long_s--; shrink_turn = 'short'; } else if (short_s > shrinkLimit) { short_s--; shrink_turn = 'long'; } else break; } else { if (short_s > shrinkLimit) { short_s--; shrink_turn = 'long'; } else if (long_s > shrinkLimit) { long_s--; shrink_turn = 'short'; } else break; } }
|
||
else if (hasLong) { if (long_s > shrinkLimit) long_s--; else break; } else { if (short_s > shrinkLimit) short_s--; else break; }
|
||
}
|
||
}
|
||
if (sizes.length === 0) sizes = names.map(name => ({ name: name, size: shrinkLimit, isMulti: false, isShort: true }));
|
||
|
||
let $namelist = $(`<div class='nameList'></div>`).css({ width: `${w}px`, height: `${h}px`, "column-gap": `${v_gap}px` });
|
||
sizes.forEach(item => {
|
||
let minH = item.isShort ? `${th}px` : `${h}px`;
|
||
let alignStyle = item.isMulti || (!item.isMulti && !item.isShort) ? "start" : "justify";
|
||
$namelist.append($(`<span class="nameSpan" style="font-size: ${item.size}pt; min-height: ${minH}; max-height: ${h}px; text-align: ${alignStyle}; text-align-last: ${alignStyle}; margin-left: 5px;">${item.name}</span>`));
|
||
});
|
||
return $namelist;
|
||
},
|
||
|
||
buildLiveList(names, d) {
|
||
let w = parseFloat(d.width), h = parseFloat(d.height), th = parseFloat(d.textHeight) || h, v_gap = parseFloat(d.textSpan) || 0, h_gap = 5;
|
||
let shrinkLimit = (h < 300) ? 8 : ((h < 500) ? 6 : 4), s = parseFloat(d.fontSize);
|
||
|
||
while (s >= shrinkLimit) {
|
||
let size_px = s * 1.333, current_col_h = 0, current_col_w = 0, cols_width = [], any_wrapped = false;
|
||
for (let i = 0; i < names.length; i++) {
|
||
let l = names[i].length, item_h = Math.max(th, l * size_px);
|
||
if (item_h > (h - 5)) {
|
||
any_wrapped = true;
|
||
if (current_col_h > 0) { cols_width.push(current_col_w); current_col_h = 0; current_col_w = 0; }
|
||
cols_width.push(Math.ceil(l / Math.max(1, Math.floor((h - 5) / size_px))) * (size_px * 1.1));
|
||
} else {
|
||
if (current_col_h === 0) { current_col_h = item_h; current_col_w = size_px; }
|
||
else if (current_col_h + v_gap + item_h <= h) { current_col_h += v_gap + item_h; if (size_px > current_col_w) current_col_w = size_px; }
|
||
else { cols_width.push(current_col_w); current_col_h = item_h; current_col_w = size_px; }
|
||
}
|
||
}
|
||
if (current_col_h > 0) cols_width.push(current_col_w);
|
||
if ((cols_width.reduce((a, b) => a + b, 0) + (cols_width.length > 1 ? (cols_width.length - 1) * h_gap : 0)) <= w && (!any_wrapped || s === shrinkLimit)) break;
|
||
s -= 1;
|
||
}
|
||
if (s < shrinkLimit) s = shrinkLimit;
|
||
|
||
let $namelist = $(`<div class='liveList'></div>`).css({ width: `${w}px`, height: `${h}px`, "column-gap": `${v_gap}px` });
|
||
names.forEach(name => $namelist.append($(`<span class="liveSpan" style="font-size: ${s}pt; min-height: ${th}px; max-height: ${h}px; margin-left: 0px;">${name}</span>`)));
|
||
return $namelist;
|
||
},
|
||
|
||
buildRosterList(names, d) {
|
||
const mid = names.length === 1 ? 1 : Math.floor(names.length / 2);
|
||
const top = names.slice(0, mid), bot = names.slice(mid);
|
||
let baseSize = parseFloat(d.fontSize), maxLen = Math.max(...names.map(n => n.length), 0);
|
||
let finalSize = maxLen > 5 ? Math.max(baseSize * (5 / maxLen), baseSize * 0.6) : baseSize;
|
||
|
||
let $wrapper = $(`<div class="rosterList"></div>`).css({ width: `${d.width}px`, height: `${d.height}px`, "writing-mode": "vertical-rl", "display": "flex", "flex-direction": "row", "align-items": "center", "justify-content": "start", "gap": "20px" });
|
||
let renderGroup = (items) => {
|
||
let $g = $(`<div class="rosterspan"></div>`).css({ "display": "flex", "flex-direction": "column", "justify-content": "center", "height": (parseFloat(d.textHeight) || parseFloat(d.height)) + 'px', "white-space": "pre-line" });
|
||
items.forEach(x => $g.append($(`<div style="letter-spacing: 10px; text-align: justify; text-align-last: justify; font-size: ${finalSize}px; font-family: ${d.fontFamily};">${x}</div>`)));
|
||
return $g;
|
||
};
|
||
$wrapper.append(renderGroup(top));
|
||
if (bot.length) $wrapper.append(renderGroup(bot));
|
||
return $wrapper;
|
||
},
|
||
|
||
buildCombinedList(names, d) {
|
||
let baseFontSize = parseFloat(d.fontSize), parts = names.join("\n").split('\n');
|
||
return $(`
|
||
<div class="ancestor-wrapper" style="width:${d.width}px !important; height:${d.height}px !important; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; writing-mode: horizontal-tb; font-family: ${d.fontFamily};">
|
||
<span class="main-name" style="line-height: 1.2; font-size: ${baseFontSize}px;">${parts[0] || ''}</span>
|
||
<span class="sub-text" style="font-size: ${baseFontSize}px; line-height: 1.2; margin-top: 4px; white-space: normal; writing-mode: vertical-rl;">${parts[1] || ''}</span>
|
||
</div>`);
|
||
},
|
||
|
||
buildNormalWithSpace(names, d, className) {
|
||
let $div = $(`<div class='${className}'></div>`).css({ "width": `${d.width}px`, "height": `${d.height}px`, "writing-mode": "vertical-rl", "display": "flex", "flex-direction": "row", "flex-wrap": "wrap", "column-gap": `${d.textSpan || 0}px`, "align-items": "flex-start", "justify-content": "start", "font-size": `${d.fontSize}px`, "font-family": d.fontFamily });
|
||
names.forEach(x => $div.append($(`<div>${x}</div>`).css({ "margin-bottom": "10px", "margin-left": "5px", "text-justify": "inter-character", "white-space": "pre-line" })));
|
||
return $div;
|
||
},
|
||
|
||
// ================= 4. UI 互動與事件綁定 =================
|
||
bindZoom() {
|
||
let updateZoom = () => { $('#scaleSize').html(Math.floor(this.zoomScale * 100) + "%"); $('#scaleSlider').val(this.zoomScale); $('#paper-preview').css("transform", `scale(${this.zoomScale})`); };
|
||
$('#scaleSlider').on("input", e => { this.zoomScale = parseFloat($(e.target).val()); updateZoom(); });
|
||
$('#zoomOut').on("click", () => { this.zoomScale = Math.max(0.1, this.zoomScale - 0.05); updateZoom(); });
|
||
$('#zoomIn').on("click", () => { this.zoomScale = Math.min(2.0, this.zoomScale + 0.05); updateZoom(); });
|
||
},
|
||
|
||
bindFloatingMenu() {
|
||
let isDragging = false, startX = 0, startY = 0;
|
||
$('#menu-header').on('mousedown', e => { isDragging = true; startX = e.clientX - $('#line-floating-menu').offset().left; startY = e.clientY - $('#line-floating-menu').offset().top; });
|
||
$(document).on('mousemove', e => { if (isDragging) $('#line-floating-menu').css({ left: (e.clientX - startX) + 'px', top: (e.clientY - startY) + 'px', bottom: 'auto' }); }).on('mouseup', () => isDragging = false);
|
||
|
||
$('#preview-container').on('click', e => {
|
||
if (e.target.id === 'preview-container' || e.target.id === 'paper-preview') {
|
||
$('#line-floating-menu').hide();
|
||
$('#editor-left .tablet-element').css('border', '1px solid transparent');
|
||
this.selectedElement = null;
|
||
}
|
||
});
|
||
|
||
$('#editor-left').on('click', '.tablet-element', e => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
this.selectedElement = $(e.currentTarget);
|
||
$('#editor-left .tablet-element').css('border', '1px solid transparent');
|
||
this.selectedElement.css('border', '1px dashed #ff7675');
|
||
this.renderItemMenu(this.selectedElement.children().first());
|
||
$('#line-floating-menu').show();
|
||
});
|
||
|
||
$('input[type="range"]').on('input', e => {
|
||
let val = parseInt($(e.target).val()); $('#lbl-fontSize').text(val);
|
||
if (this.selectedElement) {
|
||
let target = this.selectedElement.children().first();
|
||
let style = this.allStyle.find(y => y.styleID == this.selectStyle);
|
||
if (!style) return;
|
||
let d = (style.details || []).find(y => (target.hasClass("nameList") && y.elementID == "title1") || (target.hasClass("liveList") && y.elementID == "alive"));
|
||
if (d) { d.fontSize = val; this.rerenderPart(); }
|
||
}
|
||
});
|
||
},
|
||
|
||
renderItemMenu(element) {
|
||
$("#selectedItem").empty(); $("#canselectItem").empty();
|
||
let currentData = [], editLabel = "未定義區塊";
|
||
let isChaojian = (typeof VueApp !== 'undefined' && VueApp.item_type === 'B') || (this.allStyle.find(y => y.styleID == this.selectStyle)?.name || "").includes("超薦");
|
||
let requireDeceased = isChaojian;
|
||
|
||
if (element.hasClass("liveList")) { editLabel = "陽上"; currentData = this.alive; requireDeceased = false; }
|
||
else if (element.hasClass("nameList") || element.hasClass("rosterList") || element.hasClass("ancestor-wrapper")) { editLabel = "正名"; currentData = this.properTitle; }
|
||
else if (element.hasClass("lefttitle")) { editLabel = "左正名"; currentData = this.leftProperTitle; }
|
||
else if (element.hasClass("righttitle")) { editLabel = "右正名"; currentData = this.rightProperTitle; }
|
||
else if (element.hasClass("address")) { editLabel = "地址"; currentData = this.address ? [this.address] : []; }
|
||
|
||
$("#current-edit-label").text("目前編輯:" + editLabel);
|
||
|
||
currentData.forEach((item, index) => {
|
||
let displayName = typeof item === 'string' ? item : item.fam_name;
|
||
$("#selectedItem").append(`<li><span class="selected">${displayName}</span><div class="list-actions"><button class="itemUp" data-idx="${index}">↑</button><button class="itemDown" data-idx="${index}">↓</button><button class="itemRemove" data-idx="${index}">-</button></div></li>`);
|
||
});
|
||
|
||
if (element.hasClass("address")) { this.bindListEvents(); return; }
|
||
|
||
let currentNames = currentData.map(x => typeof x === 'string' ? x : x.fam_name);
|
||
let filterKeyword = $('#filter_txt').val().trim() || "";
|
||
|
||
this.familyMembers.forEach(f => {
|
||
let isDead = (f.deceased === true || f.deceased === "true" || f.deceased === "True");
|
||
if (isDead === requireDeceased && !currentNames.includes(f.fam_name) && f.fam_name.includes(filterKeyword)) {
|
||
$("#canselectItem").append(`<li><span style="opacity:0.5">卍</span> <span class="unselect">${f.fam_name}</span><span><i class="rounded-pill rounded-3 ${(f && f.fam_title) ? 'p-1' : ''}" style="background-color:lightgray">${f.fam_title ? f.fam_title : ""}</i></span><span class='itemAppend' style="float:right;cursor:pointer;">+</span></li>`);
|
||
}
|
||
});
|
||
this.bindListEvents();
|
||
},
|
||
|
||
bindListEvents() {
|
||
let self = this;
|
||
$("#canselectItem").off("click", ".itemAppend").on("click", ".itemAppend", function (e) {
|
||
e.preventDefault(); let name = $(this).siblings(".unselect").text(); let fam = self.familyMembers.find(f => f.fam_name === name);
|
||
self.modifyDataAndRerender("add", { IsShuWen: false, deceased: fam ? (fam.deceased === true || fam.deceased === "True") : false, fam_gender: "", fam_name: name, fam_file: "", nospace: true, num: fam ? fam.num : 0, option_break: false });
|
||
});
|
||
|
||
$("#selectedItem").off("click", ".itemRemove").on("click", ".itemRemove", function (e) { e.preventDefault(); self.modifyDataAndRerender("remove", $(this).data("idx")); });
|
||
$("#selectedItem").off("click", ".itemUp").on("click", ".itemUp", function (e) { e.preventDefault(); self.modifyDataAndRerender("up", $(this).data("idx")); });
|
||
$("#selectedItem").off("click", ".itemDown").on("click", ".itemDown", function (e) { e.preventDefault(); self.modifyDataAndRerender("down", $(this).data("idx")); });
|
||
|
||
$('#btn_add_custom').off("click").on('click', function (e) {
|
||
e.preventDefault(); let val = $('#custom_txt').val().trim();
|
||
if (val) { self.modifyDataAndRerender("add", { IsShuWen: false, deceased: false, fam_gender: "", fam_name: val, fam_file: "", nospace: true, num: 0, option_break: false }); $('#custom_txt').val(''); }
|
||
});
|
||
|
||
$('#filter_txt').off("keyup").on('keyup', () => { if (this.selectedElement) this.renderItemMenu(this.selectedElement.children().first()); });
|
||
|
||
$('.styleSelector').off("change").on('change', (e) => {
|
||
this.selectStyle = $(e.target).val(); this.renderEditorLeft();
|
||
});
|
||
|
||
$('#templateSel').off('change').on('change', async (e) => {
|
||
let template_num = $(e.target).val();
|
||
if (!template_num) return;
|
||
try {
|
||
this.selectedTemplate = this.tabletTempNameList.find(x => x.num == template_num);
|
||
if (!this.selectedTemplate) return;
|
||
|
||
if (this.selectedTemplate.tablet) {
|
||
let tablet = JSON.parse(this.selectedTemplate.tablet);
|
||
this.properTitle = tablet.mid_items || []; this.alive = tablet.left_items || []; this.leftProperTitle = tablet.mid_left || []; this.rightProperTitle = tablet.mid_right || []; this.address = tablet.addr_items || "";
|
||
} else { this.properTitle = []; this.alive = []; this.leftProperTitle = []; this.rightProperTitle = []; this.address = ""; }
|
||
|
||
if (this.selectedTemplate.shuwen && this.selectedTemplate.shuwen !== "[]") this.shuwenList = JSON.parse(this.selectedTemplate.shuwen);
|
||
else this.shuwenList = [...this.getExtractedNames(this.properTitle), ...this.getExtractedNames(this.leftProperTitle), ...this.getExtractedNames(this.rightProperTitle)];
|
||
|
||
this.selectStyle = this.selectedTemplate.style_id; $('.styleSelector').val(this.selectStyle);
|
||
$('#template_name_txt').val(this.selectedTemplate.name || "");
|
||
this.renderEditorLeft(); this.renderEditorRight();
|
||
if (this.selectedElement) this.renderItemMenu(this.selectedElement.children().first());
|
||
} catch (error) { alert("範本載入失敗!資料格式可能有誤。"); }
|
||
});
|
||
|
||
$('#btn_save').off('click').on('click', async function (e) {
|
||
e.preventDefault();
|
||
let tablet = { mid_items: self.properTitle, left_items: self.alive, mid_left: self.leftProperTitle, mid_right: self.rightProperTitle, addr_items: self.address, style: self.selectStyle }
|
||
let thenum = $("#templateSel").val();
|
||
let data = { num: thenum == "" ? "0" : thenum, name: $("#template_name_txt").val(), follower_num: self.follower_id, style_id: $(".styleSelector").val(), tablet: JSON.stringify(tablet), shuwen: JSON.stringify(self.shuwenList) }
|
||
await axios.post('/api/follower/SaveTabletTemplate', data).then(res => {
|
||
if (res.status == 200) {
|
||
|
||
//alert("儲存成功!");
|
||
VueApp.snackbar.show = true;
|
||
VueApp.snackbar.message = "儲存成功";
|
||
self.getTemplateNameList();
|
||
|
||
// (可選) 如果您希望存檔後,下拉選單保持在剛存好的那個範本,
|
||
// 可以在這裡把 thenum 塞回去(前提是 API 有回傳新 ID 或原本就是更新舊檔)
|
||
if (thenum !== "") {
|
||
$("#templateSel").val(thenum);
|
||
}
|
||
} else {
|
||
VueApp.snackbar.show = true;
|
||
VueApp.snackbar.message = "儲存失敗";
|
||
};
|
||
});
|
||
});
|
||
|
||
// ==========================================
|
||
// 🌟 4. 新增範本按鈕事件 (清空所有資料)
|
||
// ==========================================
|
||
$('#btn_new_template').off('click').on('click', (e) => {
|
||
e.preventDefault();
|
||
|
||
// 1. 清空頂端的選單與名稱
|
||
$("#templateSel").val("");
|
||
$("#template_name_txt").val("");
|
||
|
||
// 2. 清空所有名單資料與狀態
|
||
this.selectedTemplate = {};
|
||
this.properTitle = [];
|
||
this.alive = [];
|
||
this.leftProperTitle = [];
|
||
this.rightProperTitle = [];
|
||
this.address = "";
|
||
this.shuwenList = [];
|
||
|
||
// 3. 重新渲染畫面 (這時會印出空的牌位區塊)
|
||
this.renderEditorLeft();
|
||
this.renderEditorRight();
|
||
|
||
// 4. 如果浮動選單目前開著,也要同步清空選單裡的「已選」清單
|
||
if (this.selectedElement) {
|
||
let target = this.selectedElement.children().first();
|
||
this.renderItemMenu(target);
|
||
}
|
||
});
|
||
},
|
||
|
||
modifyDataAndRerender(action, payload) {
|
||
if (!this.selectedElement) return;
|
||
let target = this.selectedElement.children().first(), targetArr = null;
|
||
|
||
if (target.hasClass("liveList")) targetArr = this.alive;
|
||
else if (target.hasClass("nameList") || target.hasClass("rosterList") || target.hasClass("ancestor-wrapper")) targetArr = this.properTitle;
|
||
else if (target.hasClass("lefttitle")) targetArr = this.leftProperTitle;
|
||
else if (target.hasClass("righttitle")) targetArr = this.rightProperTitle;
|
||
else if (target.hasClass("address")) {
|
||
if (action === "add") this.address = typeof payload === 'string' ? payload : payload.fam_name;
|
||
else if (action === "remove") this.address = ""; targetArr = null;
|
||
}
|
||
|
||
if (targetArr) {
|
||
if (action === "add") {
|
||
if (targetArr.some(x => x.fam_name === payload.fam_name)) { alert("名稱已存在!"); return; }
|
||
targetArr.push(payload);
|
||
if (targetArr !== this.alive && targetArr !== null) this.shuwenList.push(payload.fam_name);
|
||
} else if (action === "remove") {
|
||
if (targetArr !== this.alive && targetArr !== null) {
|
||
let nameToRemove = typeof targetArr[payload] === 'string' ? targetArr[payload] : targetArr[payload].fam_name;
|
||
let sIdx = this.shuwenList.indexOf(nameToRemove); if (sIdx > -1) this.shuwenList.splice(sIdx, 1);
|
||
}
|
||
targetArr.splice(payload, 1);
|
||
} else if (action === "up" && payload > 0) { [targetArr[payload - 1], targetArr[payload]] = [targetArr[payload], targetArr[payload - 1]]; }
|
||
else if (action === "down" && payload < targetArr.length - 1) { [targetArr[payload], targetArr[payload + 1]] = [targetArr[payload + 1], targetArr[payload]]; }
|
||
}
|
||
this.renderItemMenu(target); this.rerenderPart(); this.renderEditorRight();
|
||
}
|
||
};
|
||
|
||
$(() => { designer.init(); });
|
||
</script>
|