1034 lines
48 KiB
HTML
1034 lines
48 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: none !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>
|
||
<!-- 頂端控制列 (依據 editorNewOne_2.html) -->
|
||
<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>
|
||
<!-- 這裡的選項可透過 API 動態載入 -->
|
||
</select>
|
||
</div>
|
||
<div class="col-3">
|
||
範本名稱:
|
||
<input type="text" id="template_name_txt" class="form-control" placeholder="" />
|
||
</div>
|
||
<div class="col-2">
|
||
<button class="btn btn-primary" id="btn_save">儲存牌位</button>
|
||
</div>
|
||
<div class="col-5">
|
||
<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">123</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,
|
||
tabletItem: {},
|
||
familyMembers: [],
|
||
allStyle: [],
|
||
allStyleDetails: [],
|
||
allSize: [],
|
||
tabletElement: [],
|
||
papers: [],
|
||
elements:[],
|
||
http_host: "",
|
||
follower_id: "",
|
||
follower_num:"",
|
||
// 動態名單資料
|
||
properTitle: [],
|
||
leftProperTitle: [],
|
||
rightProperTitle: [],
|
||
alive: [],
|
||
address: "",
|
||
currentTarget: "",
|
||
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.getTabletStyles();
|
||
this.bindZoom();
|
||
this.bindFloatingMenu();
|
||
this.bindListEvents();
|
||
this.follower_id = VueApp.follower_id;
|
||
//console.log(this.follower_num,":",VueApp.follower_id);
|
||
this.getFamilies();
|
||
this.getTemplateNameList();
|
||
},
|
||
//抓親友資料
|
||
async getFamilies() {
|
||
await axios.post('/api/follower/GetFollowerFamilies', { num: VueApp.follower_id }).then(res => {
|
||
if (res.status == 200 && res.data.list) {
|
||
this.familyMembers = res.data.list;
|
||
}
|
||
});
|
||
},
|
||
// ================= 1. API 資料撈取 =================
|
||
//async getAllStyleDetails() {
|
||
// await axios.post('/api/tablet/GetStyleDetailData', {}).then(res => {
|
||
// if (res.status == 200 && res.data.result == "Y") this.allStyleDetails = res.data.data;
|
||
// });
|
||
//},
|
||
//async getPaperSize() {
|
||
// await axios.post('/api/tablet/GetPaperSize', {}).then(res => {
|
||
// if (res.status == 200) this.allSize = res.data.data;
|
||
// });
|
||
//},
|
||
//async getTabletElement() {
|
||
// await axios.post('/api/tablet/GetTabletElement', {}).then(res => {
|
||
// if (res.status == 200) this.tabletElement = res.data.data;
|
||
// });
|
||
//},
|
||
async getTabletStyles() {
|
||
await axios.post('/api/tablet/GetTabletStyleList', {}).then(res => {
|
||
if (res.status == 200 && res.data.list) {
|
||
this.allStyle = [];
|
||
// 🌟 改用 Class
|
||
$('.styleSelector').empty().append('<option value="">請選擇版型</option>');
|
||
res.data.list.forEach(x => {
|
||
if (x.styleID !== "000001") {
|
||
this.allStyle.push(x);
|
||
// 🌟 改用 Class
|
||
$('.styleSelector').append(`<option value="${x.styleID}">${x.name}</option>`);
|
||
}
|
||
});
|
||
|
||
this.papers = res.data.papers;
|
||
this.elements = res.data.elements;
|
||
|
||
//console.log("getTabletStyles:",this.allStyle, this.papers, this.elements);
|
||
}
|
||
});
|
||
},
|
||
async getData() {
|
||
this.properTitle = []; this.leftProperTitle = []; this.rightProperTitle = []; this.alive = [];
|
||
//let param = [{ order_no: this.tabletItem.order_no, num: this.tabletItem.num }];
|
||
|
||
await axios.post('/api/follower/GetTabletTemplateList', { follower_num:VueApp.follower_id}).then(res => {
|
||
if (res.status == 200) {
|
||
res.data.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.length = 0;
|
||
await axios.post('/api/follower/GetTabletTemplateNameList', { num: VueApp.follower_id }).then(res => {
|
||
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. 動態渲染排版 =================
|
||
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})` });
|
||
// 🌟 3. 設定右側 (#editor-right) 的尺寸:寬度永遠鎖定 A4 橫向 (297mm),高度跟著版型
|
||
$('#editor-right').css({
|
||
width: '297mm',
|
||
height: Number(size.height) + 'mm'
|
||
});
|
||
let details = style.details; //this.elements.filter(y => y.styleID == style.styleID);
|
||
|
||
// 依據版型設定動態產生內部區塊
|
||
// 依據版型設定動態產生內部區塊
|
||
details.forEach(d => {
|
||
if (d.isActive != "hidden") {
|
||
let html = "";
|
||
let currentList = [];
|
||
|
||
if (d.elementID === 'title1') {
|
||
currentList = this.properTitle.length > 0 ? this.properTitle : ["請選擇"];
|
||
html = this.buildNameList(currentList, d);
|
||
} else if (d.elementID === 'titletriangle') { // 🌟 加入品字佈局
|
||
currentList = this.properTitle.length > 0 ? this.properTitle : ["請選擇"];
|
||
html = this.buildRosterList(currentList, d);
|
||
} else if (d.elementID === 'combined' || d.elementID === 'tricombined') { // 🌟 加入合併佈局
|
||
currentList = this.properTitle.length > 0 ? this.properTitle : ["請選擇"];
|
||
html = this.buildCombinedList(currentList, d);
|
||
} else if (d.elementID === 'alive') {
|
||
currentList = this.alive.length > 0 ? this.alive : ["請選擇"];
|
||
html = this.buildLiveList(currentList, d);
|
||
} else if (d.elementID === 'lefttitle') {
|
||
currentList = this.leftProperTitle.length > 0 ? this.leftProperTitle : ["請選擇"];
|
||
html = this.buildNormalWithSpace(currentList, d, 'lefttitle');
|
||
} else if (d.elementID === 'righttitle') {
|
||
currentList = this.rightProperTitle.length > 0 ? this.rightProperTitle : ["請選擇"];
|
||
html = this.buildNormalWithSpace(currentList, d, 'righttitle');
|
||
} else if (d.elementID === 'address') {
|
||
html = `<div class='address' style='width:${d.width}px; height:${d.height}px; font-size:${d.fontSize}px; font-family:${d.fontFamily};'>${this.address || "請選擇"}</div>`;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// 完美修正版正名演算
|
||
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 (testNames.length === 1 && testNames[0].includes("暨無始劫以來累世冤親債主")) {
|
||
testNames[0] = testNames[0].split("暨")[0] + "暨";
|
||
}
|
||
|
||
let items = testNames.map((name, i) => {
|
||
let l = name.length;
|
||
let 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;
|
||
let v_gap = parseFloat(d.textSpan) || 0, h_gap = 5;
|
||
let shrinkLimit = (h < 300) ? 8 : ((h < 500) ? 6 : 4);
|
||
let 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;
|
||
},
|
||
|
||
// ================= 3. 浮動選單與互動綁定 =================
|
||
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 d = this.allStyleDetails.find(y => y.styleID == this.selectStyle && (
|
||
(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 = "未定義區塊";
|
||
|
||
// 🌟 1. 判斷是否為超薦牌位 (利用 item_type === 'B' 或是依據底圖名稱防呆)
|
||
let isChaojian = false;
|
||
if (this.item_type === 'B' || (typeof VueApp !== 'undefined' && VueApp.item_type === 'B') || (this.tabletItem && this.tabletItem.item_type === 'B')) {
|
||
isChaojian = true;
|
||
} else {
|
||
let style = this.allStyle.find(y => y.styleID == this.selectStyle);
|
||
if (style && style.name && style.name.includes("超薦")) {
|
||
isChaojian = true;
|
||
}
|
||
}
|
||
|
||
// 預設生死條件:超薦要死者(true),消災要活者(false)
|
||
let requireDeceased = isChaojian;
|
||
|
||
// 🌟 2. 判斷當前編輯區塊
|
||
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((x, index) => {
|
||
$("#selectedItem").append(`
|
||
<li><span class="selected">${x}</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;
|
||
}
|
||
|
||
// 🌟 3. 渲染可選 (嚴格過濾生死狀態)
|
||
let filterKeyword = $('#filter_txt').val().trim() || "";
|
||
this.familyMembers.forEach(f => {
|
||
// 判斷該親友的生死狀態 (相容 boolean 與字串)
|
||
let isDead = (f.deceased === true || f.deceased === "true" || f.deceased === "True");
|
||
|
||
// 條件成立:生死符合要求 + 尚未加入 + 關鍵字相符
|
||
if (isDead === requireDeceased && !currentData.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;
|
||
|
||
// 🌟 修正1:加上 .off() 解除舊綁定,防止「幽靈疊加點擊」!
|
||
// 否則您每點一次,它會因為 renderItemMenu 重新綁定,導致一次加好幾個名字甚至當掉
|
||
$("#canselectItem").off("click", ".itemAppend").on("click", ".itemAppend", function (e) {
|
||
e.preventDefault();
|
||
self.modifyDataAndRerender("add", $(this).siblings(".unselect").text());
|
||
});
|
||
|
||
$("#selectedItem").off("click", ".itemRemove").on("click", ".itemRemove", function (e) {
|
||
e.preventDefault();
|
||
self.modifyDataAndRerender("remove", $(this).closest("li").find(".selected").text());
|
||
});
|
||
|
||
$("#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", val); $('#custom_txt').val(''); }
|
||
});
|
||
|
||
$('#filter_txt').off("keyup").on('keyup', () => {
|
||
if (this.selectedElement) this.renderItemMenu(this.selectedElement.children().first());
|
||
});
|
||
|
||
$('.styleSelector').off("change").on('change', (e) => {
|
||
let val = $(e.target).val();
|
||
$('.styleSelector').val(val); // 雙向同步
|
||
this.selectStyle = val;
|
||
this.renderEditorLeft();
|
||
});
|
||
|
||
|
||
$('#templateSel').off('change').on('change', async (e) => {
|
||
let template_num = $(e.target).val();
|
||
if (!template_num) return;
|
||
//抓回範本資料
|
||
try {
|
||
let val = $(e.target).val();
|
||
this.selectedTemplate = this.tabletTempNameList.find(x=>x.num===template_num);
|
||
} catch (error) {
|
||
console.error("範本載入失敗:", error);
|
||
alert("範本載入失敗!");
|
||
}
|
||
});
|
||
|
||
// ==========================================
|
||
// 🌟 3. 儲存按鈕事件 (含範本資料)
|
||
// ==========================================
|
||
$('#btn_save').off('click').on('click', async function (e) {
|
||
e.preventDefault();
|
||
//let tablet = {
|
||
// mid_items: designer.family_deceased_N_selected,
|
||
// left_items: designer.family_deceased_Y_selected,
|
||
// style: designer.selectStyle.style,
|
||
// mid_left: designer.family_left_title,
|
||
// mid_right: designer.family_right_title,
|
||
// addr_items: designer.family_address
|
||
//}
|
||
let data = {
|
||
num: 0, name: $("#template_name_txt").val(), follower_num: VueApp.follower_id,
|
||
style_id: $(".styleSelector").val(),tablet:"",shuwen:""
|
||
}
|
||
console.log("btn_save:", data);
|
||
});
|
||
},
|
||
|
||
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")) 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 = payload; else if (action === "remove") this.address = "";
|
||
targetArr = this.address ? [this.address] : [];
|
||
}
|
||
|
||
if (targetArr) {
|
||
if (action === "add" && !targetArr.includes(payload)) targetArr.push(payload);
|
||
else if (action === "add" && targetArr.includes(payload)) { alert("名稱已存在!"); return; }
|
||
else if (action === "remove") { let idx = targetArr.indexOf(payload); if (idx > -1) targetArr.splice(idx, 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();
|
||
},
|
||
|
||
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 details = style.details || this.allStyleDetails.filter(y => y.styleID == this.selectStyle);
|
||
|
||
// 🌟 加入尋找設定檔的判斷
|
||
let d = 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();
|
||
|
||
let html = "";
|
||
// 🌟 重新導向對應的排版引擎
|
||
if (target.hasClass("nameList")) html = this.buildNameList(this.properTitle, d);
|
||
else if (target.hasClass("rosterList")) html = this.buildRosterList(this.properTitle, d);
|
||
else if (target.hasClass("ancestor-wrapper")) html = this.buildCombinedList(this.properTitle, d);
|
||
else if (target.hasClass("liveList")) html = this.buildLiveList(this.alive, d);
|
||
else if (target.hasClass("lefttitle")) html = this.buildNormalWithSpace(this.leftProperTitle, d, 'lefttitle');
|
||
else if (target.hasClass("righttitle")) html = this.buildNormalWithSpace(this.rightProperTitle, d, 'righttitle');
|
||
else if (target.hasClass("address")) html = `<div class='address' style='width:${d.width}px; height:${d.height}px; font-size:${d.fontSize}px; font-family:${d.fontFamily};'>${this.address}</div>`;
|
||
|
||
this.selectedElement.append(html);
|
||
},
|
||
// 縮放控制
|
||
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(); });
|
||
},
|
||
// 🌟 1. 品字排版引擎 (titletriangle)
|
||
buildRosterList(names, d) {
|
||
if (!names.length) names = ["請選擇"];
|
||
const mid = names.length === 1 ? 1 : Math.floor(names.length / 2);
|
||
const top = names.slice(0, mid);
|
||
const bot = names.slice(mid);
|
||
|
||
// 計算自動縮小字體
|
||
let baseSize = parseFloat(d.fontSize);
|
||
const 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;
|
||
},
|
||
|
||
// 🌟 2. 合併多姓氏排版引擎 (combined)
|
||
buildCombinedList(names, d) {
|
||
if (!names.length) names = ["請選擇"];
|
||
let baseFontSize = parseFloat(d.fontSize);
|
||
|
||
// 取前兩筆作為 main-name 與 sub-text
|
||
const parts = names.join("\n").split('\n');
|
||
|
||
// 因為這個元素需要水平包覆垂直,所以把 CSS 直接寫進 inline-style
|
||
let html = `
|
||
<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>`;
|
||
|
||
return $(html);
|
||
},
|
||
// 🌟 完美還原左右正名的換行與間距排版
|
||
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;
|
||
},
|
||
// 🌟 處理右側 Editor 同步渲染 (強制一行一個)
|
||
renderEditorRight() {
|
||
let $editorRight = $('#editor-right');
|
||
$editorRight.empty();
|
||
|
||
// 1. 設定為直書模式,拔除 flex,恢復自然的區塊(Block)換行特性
|
||
$editorRight.css({
|
||
"writing-mode": "vertical-rl",
|
||
"text-orientation": "mixed",
|
||
"padding": "20px",
|
||
"display": "block" // 🌟 關鍵:改用 block,讓每個子元素絕對獨佔一整行
|
||
});
|
||
|
||
// 2. 收集所有要同步的名單
|
||
let syncNames = [
|
||
...this.properTitle,
|
||
...this.leftProperTitle,
|
||
...this.rightProperTitle
|
||
];
|
||
|
||
// 3. 渲染名單
|
||
syncNames.forEach(name => {
|
||
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" // 🌟 設定行距 (在直書模式中,左邊就是下一行的位置)
|
||
}).text(name);
|
||
|
||
$editorRight.append($div);
|
||
});
|
||
},
|
||
};
|
||
|
||
// ================= 4. 啟動與 Message 接聽 =================
|
||
//$(window).on("message", function (event) {
|
||
// let e = event.originalEvent;
|
||
// if (e.origin === window.location.origin || e.origin === window.parent.location.origin) {
|
||
// const data = e.data;
|
||
// console.log("template load:",data)
|
||
// //if (data.tabletItem) {
|
||
// // designer.tabletItem = data.tabletItem;
|
||
// // if (data.familyMembers) designer.familyMembers = data.familyMembers;
|
||
// // designer.getData();
|
||
// //}
|
||
// }
|
||
//});
|
||
|
||
$(() => {
|
||
designer.init();
|
||
|
||
});
|
||
</script> |