Files

863 lines
45 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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;">&#43;</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>