class TreeSelector { constructor(app) { this.app = app; this.treeCollapseState = this.loadCollapseCache(); // Try desktop container first, fallback to drawer container (mobile), else create one this.controlsContentDiv = document.getElementById("controls-content") || document.getElementById('drawer-controls-content'); if (!this.controlsContentDiv) { const controlsRoot = document.getElementById('controls'); if (controlsRoot) { const div = document.createElement('div'); div.id = 'controls-content'; controlsRoot.insertBefore(div, controlsRoot.firstChild); this.controlsContentDiv = div; } else if (document.body) { const div = document.createElement('div'); div.id = 'controls-content'; document.body.appendChild(div); this.controlsContentDiv = div; } } this.__treeSelectorRendered = false; } getCollapseCacheKey() { return `tree_collapse_${this.app.currentCharacter}`; } saveCollapseCache() { try { localStorage.setItem(this.getCollapseCacheKey(), JSON.stringify(this.treeCollapseState)); } catch (e) {} } loadCollapseCache() { let cache = localStorage.getItem(this.getCollapseCacheKey()); if (!cache) return {}; try { return JSON.parse(cache); } catch (e) { return {}; } } getSelectionCacheKey() { return `character_selection_${this.app.currentCharacter}`; } saveSelectionCache(rootNode) { function collect(node) { let result = {}; if (node.Children) { for (let key in node.Children) result[key] = collect(node.Children[key]); } if (node.SpriteRenderer && node.SpriteRenderer.Sprite) result.__selected = !!node.__selected; return result; } try { localStorage.setItem(this.getSelectionCacheKey(), JSON.stringify(collect(rootNode))); } catch (e) {} } loadSelectionCache(rootNode) { let cache = localStorage.getItem(this.getSelectionCacheKey()); if (!cache) return; try { cache = JSON.parse(cache); } catch (e) { return; } function restore(node, cacheObj) { if (!cacheObj) return; if (node.Children) { for (let key in node.Children) restore(node.Children[key], cacheObj[key]); } if (node.SpriteRenderer && node.SpriteRenderer.Sprite && typeof cacheObj.__selected !== "undefined") { node.__selected = !!cacheObj.__selected; } } restore(rootNode, cache); } clearSelectionCache() { localStorage.removeItem(this.getSelectionCacheKey()); } // 判断节点或其子节点是否含有 SpriteRenderer.Sprite static hasLeafSprite(node) { if (node.SpriteRenderer && node.SpriteRenderer.Sprite) return true; if (node.Children) { for (let key in node.Children) { if (TreeSelector.hasLeafSprite(node.Children[key])) return true; } } return false; } // 新增:返回按 Name 排序的子节点键数组(避免重复排序逻辑) static sortedChildKeys(children) { if (!children) return []; return Object.keys(children).sort((a, b) => { const nameA = (children[a] && children[a].Name) || ""; const nameB = (children[b] && children[b].Name) || ""; return nameA.localeCompare(nameB, 'zh-CN'); }); } // 收集所有选中的叶节点(多选) getSelectedLeafNodes(node) { let result = []; let isLeaf = !node.Children || Object.keys(node.Children).length === 0; if (isLeaf && node.SpriteRenderer && node.SpriteRenderer.Sprite && node.__selected) { result.push(node); } if (node.Children) { for (let key in node.Children) { result = result.concat(this.getSelectedLeafNodes(node.Children[key])); } } return result; } // 同名连锁同步 syncLinkedLeafsByName(name, selected) { // 收集所有匹配节点(保持原来包含匹配逻辑) let matches = []; function traverseCollect(node) { if (node.SpriteRenderer && node.SpriteRenderer.Sprite && node.Name && node.Name !== name && node.Name.includes(name)) { matches.push(node); } if (node.Children) { for (let key in node.Children) traverseCollect(node.Children[key]); } } traverseCollect(this.app.rootNode); if (!matches.length) return; // 正则检测后缀形如 _01、_02、_3 等 const suffixRe = /^(.*)_(\d+)$/; // 分组:带后缀按前缀分组,不带后缀单独处理 let groups = new Map(); // key: prefix, value: array of {node, idx} for (let node of matches) { let m = node.Name.match(suffixRe); if (m) { let prefix = m[1]; let idx = parseInt(m[2], 10); if (!groups.has(prefix)) groups.set(prefix, []); groups.get(prefix).push({ node, idx }); } else { // 非后缀直接设置(和之前行为一致) node.__selected = selected; } } // 对每个带后缀的组,只选择 idx 最小的一个,其余取消选择 for (let [prefix, arr] of groups.entries()) { // 找到最小 idx 的节点(如果相同 idx 多个,选第一个遇到的) arr.sort((a, b) => a.idx - b.idx); let first = arr[0]; // 设定选中状态:第一个为 selected,其余为 false first.node.__selected = selected; for (let i = 1; i < arr.length; i++) { arr[i].node.__selected = false; } } } setLeafSelected(node, selected) { node.__selected = selected; if (node.Name) this.syncLinkedLeafsByName(node.Name, selected); } selectEnabledIfNoneSelected(otherLeafs) { let hasSelected = otherLeafs.some(n => n.__selected); if (!hasSelected && otherLeafs.length > 0) { let enabledIdx = otherLeafs.findIndex(n => n.SpriteRenderer.Enabled); otherLeafs.forEach((n, i) => this.setLeafSelected(n, i === enabledIdx && enabledIdx !== -1)); } } selectEnabledIfNoneSelectedByNode(node) { if (node && node.Children) { let otherLeafs = Object.values(node.Children).filter(n => n.SpriteRenderer && n.SpriteRenderer.Sprite); this.selectEnabledIfNoneSelected(otherLeafs); } } // 渲染整个树(与原 renderTreeSelector 行为对应) render(rootNode) { // Ensure we have a container to render into. Try to resolve again in case DOM changed. if (!this.controlsContentDiv) { this.controlsContentDiv = document.getElementById("controls-content") || document.getElementById('drawer-controls-content'); } if (!this.controlsContentDiv) return; // nothing to render into yet this.controlsContentDiv.innerHTML = ""; // 回读折叠状态(保持) this.treeCollapseState = this.loadCollapseCache(); const isFirstRender = !window.__treeSelectorRendered; window.__treeSelectorRendered = true; let hasCache = false; if (isFirstRender) { hasCache = !!localStorage.getItem(this.getSelectionCacheKey()); if (hasCache) this.loadSelectionCache(rootNode); } // 寻找特殊节点引用 let armsNode = null, armLNode = null, armRNode = null; function findSpecialNodes(node) { if (node.Name === "Arms") armsNode = node; if (node.Name === "ArmL") armLNode = node; if (node.Name === "ArmR") armRNode = node; if (node.Children) for (let k in node.Children) findSpecialNodes(node.Children[k]); } findSpecialNodes(rootNode); // 递归渲染 const singleSelectGroups = ["ArmL", "ArmR", "Arms", "Eyes", "Mouth", "Eyes01", "Mouth01"]; const that = this; function renderNode(node, path = []) { if (!TreeSelector.hasLeafSprite(node)) return null; const nodeDiv = document.createElement("div"); nodeDiv.className = "tree-parent-group"; const headerDiv = document.createElement("div"); headerDiv.className = "tree-header"; headerDiv.style.cursor = "pointer"; const nodeKey = path.concat(node.Name || "").join("/"); let collapsed = !!that.treeCollapseState[nodeKey]; const toggleBtn = document.createElement("span"); toggleBtn.className = "tree-toggle"; toggleBtn.textContent = collapsed ? "▶" : "▼"; headerDiv.appendChild(toggleBtn); const titleSpan = document.createElement("span"); titleSpan.className = "tree-title"; titleSpan.textContent = node.Name || "(未命名)"; headerDiv.appendChild(titleSpan); nodeDiv.appendChild(headerDiv); const gridDiv = document.createElement("div"); gridDiv.className = "thumb-grid"; gridDiv.style.display = collapsed ? "none" : "grid"; // 收集直接叶节点子节点并排序 let leafNodes = []; if (node.Children) { const sortedKeys = TreeSelector.sortedChildKeys(node.Children); for (let key of sortedKeys) { let child = node.Children[key]; if (child.SpriteRenderer && child.SpriteRenderer.Sprite) leafNodes.push(child); } } const isSingleSelect = singleSelectGroups.includes(node.Name); // 首次渲染初始化选择状态(无缓存) if (isFirstRender && leafNodes.length > 0 && !hasCache) { let hasEnabled = leafNodes.some(n => n.SpriteRenderer.Enabled); if (isSingleSelect) { let idx = leafNodes.findIndex(n => n.SpriteRenderer.Enabled); leafNodes.forEach((n, i) => n.__selected = (i === idx && hasEnabled ? true : false)); } else { leafNodes.forEach(n => n.__selected = !!n.SpriteRenderer.Enabled && hasEnabled); } } leafNodes.forEach((childNode, idx) => { const thumbDiv = document.createElement("div"); thumbDiv.className = "thumb-node"; if (childNode.__selected) thumbDiv.classList.add("selected"); const img = document.createElement("img"); img.className = "thumb-img"; img.src = `/images/character/${that.app.currentCharacter}/${childNode.SpriteRenderer.Sprite.Name}.webp`; img.alt = childNode.Name || ""; thumbDiv.appendChild(img); const nameDiv = document.createElement("div"); nameDiv.className = "thumb-name"; nameDiv.textContent = childNode.Name || "(未命名)"; thumbDiv.appendChild(nameDiv); thumbDiv.onclick = function() { // Arms 与 ArmL/ArmR 互斥逻辑,同原实现 if (node.Name === "Arms" && childNode.__selected === false) { if (armLNode && armLNode.Children) Object.values(armLNode.Children).forEach(n => that.setLeafSelected(n, false)); if (armRNode && armRNode.Children) Object.values(armRNode.Children).forEach(n => that.setLeafSelected(n, false)); } else if (node.Name === "Arms" && childNode.__selected === true) { that.selectEnabledIfNoneSelectedByNode(armLNode); that.selectEnabledIfNoneSelectedByNode(armRNode); } if ((node.Name === "ArmL" || node.Name === "ArmR") && !childNode.__selected) { if (armsNode && armsNode.Children) { Object.values(armsNode.Children) .filter(n => n.SpriteRenderer && n.SpriteRenderer.Sprite) .forEach(n => that.setLeafSelected(n, false)); } let otherNode = node.Name === "ArmL" ? armRNode : armLNode; that.selectEnabledIfNoneSelectedByNode(otherNode); } if (isSingleSelect) { // 新增:如果是 ArmL / ArmR 分组,点击已选项时直接忽略(禁止手动取消) if ((node.Name === "ArmL" || node.Name === "ArmR") && childNode.__selected) { return; } if (childNode.__selected) { that.setLeafSelected(childNode, false); } else { leafNodes.forEach(n => { if (n.__selected) that.setLeafSelected(n, false); }); that.setLeafSelected(childNode, true); } } else { that.setLeafSelected(childNode, !childNode.__selected); } that.saveSelectionCache(rootNode); that.render(rootNode); that.app.renderer.composeAndDraw(); }; gridDiv.appendChild(thumbDiv); }); nodeDiv.appendChild(gridDiv); const childrenDiv = document.createElement("div"); childrenDiv.className = "tree-children"; childrenDiv.style.display = collapsed ? "none" : "block"; if (node.Children) { const sortedKeys = TreeSelector.sortedChildKeys(node.Children); for (let key of sortedKeys) { let child = node.Children[key]; if (child.Children && Object.keys(child.Children).length > 0) { const childTree = renderNode(child, path.concat(node.Name || "")); if (childTree) childrenDiv.appendChild(childTree); } } } nodeDiv.appendChild(childrenDiv); headerDiv.onclick = function() { collapsed = !collapsed; gridDiv.style.display = collapsed ? "none" : "grid"; childrenDiv.style.display = collapsed ? "none" : "block"; toggleBtn.textContent = collapsed ? "▶" : "▼"; that.treeCollapseState[nodeKey] = collapsed; that.saveCollapseCache(); }; return nodeDiv; } const tree = renderNode(rootNode); if (tree) this.controlsContentDiv.appendChild(tree); } } class Renderer { constructor(app) { this.app = app; this.offscreenCanvas = null; this.offscreenCtx = null; this.offscreenWidth = 0; this.offscreenHeight = 0; this.maskAlphaCache = {}; // 缓存 mask this.firstDraw = true; } // 主画布绘制(使用 app.view 与 offscreenCanvas) drawToMainCanvas() { const canvas = this.app.canvas; const ctx = this.app.ctx; if (!canvas || !ctx) return; // Clear using display size aware clearing ctx.clearRect(0, 0, canvas.width, canvas.height); if (!this.offscreenCanvas) return; // Ensure backing store matches display size let dpr = window.devicePixelRatio || 1; const displayWidth = Math.round(canvas.clientWidth * dpr); const displayHeight = Math.round(canvas.clientHeight * dpr); if (canvas.width !== displayWidth || canvas.height !== displayHeight) { canvas.width = displayWidth; canvas.height = displayHeight; } ctx.save(); if (this.app.panzoom && this.app.panzoom.isActive) { // When panzoom is active the visual transform is handled by CSS transform // so just draw the offscreen canvas to fit the physical canvas try { ctx.drawImage(this.offscreenCanvas, 0, 0, canvas.width, canvas.height); } catch (e) { // fallback if drawImage with sizing fails ctx.drawImage(this.offscreenCanvas, 0, 0); } } else { let scale = this.app.view.scale || 1; let offsetX = (this.app.view.offsetX || 0) * dpr; let offsetY = (this.app.view.offsetY || 0) * dpr; if (this.firstDraw && this.offscreenWidth > 0 && this.offscreenHeight > 0) { let scaleX = canvas.width / this.offscreenWidth; let scaleY = canvas.height / this.offscreenHeight; scale = Math.min(scaleX, scaleY, 1); offsetX = (canvas.width - this.offscreenWidth * scale) / 2; offsetY = (canvas.height - this.offscreenHeight * scale) / 2; this.app.view.scale = scale; this.app.view.offsetX = offsetX / dpr; this.app.view.offsetY = offsetY / dpr; this.firstDraw = false; } ctx.translate(offsetX, offsetY); ctx.scale(scale, scale); ctx.drawImage(this.offscreenCanvas, 0, 0); } ctx.restore(); } // 合成并绘制(核心函数,参考原 composeAndDraw) composeAndDraw() { const t0 = performance.now(); let selectedNodes = this.app.selector.getSelectedLeafNodes(this.app.rootNode); selectedNodes.sort((a, b) => { let sa = a.SpriteRenderer.SortingOrder || 0; let sb = b.SpriteRenderer.SortingOrder || 0; return sa - sb; }); const t1 = performance.now(); console.log(`[composeAndDraw] 选中节点收集与排序耗时: ${(t1-t0).toFixed(2)}ms`); let loadPromises = selectedNodes.map(node => { let sprite = node.SpriteRenderer.Sprite; if (!sprite || !sprite.Name) return Promise.resolve(null); let img = new Image(); img.src = `/images/character/${this.app.currentCharacter}/${sprite.Name}.webp`; return new Promise(resolve => { img.onload = () => resolve({img, node}); img.onerror = () => resolve(null); }); }); loadPromises.length === 0 && console.log("[composeAndDraw] 无需加载图片"); Promise.all(loadPromises).then(results => { const t2 = performance.now(); console.log(`[composeAndDraw] 图片加载耗时: ${(t2-t1).toFixed(2)}ms`); let imagesInfo = []; let bboxes = []; results.forEach(res => { if (!res) return; let {img, node} = res; let trans = node.Transform || {}; let pos = trans.Position || {x:0, y:0, z:0}; let scale = trans.Scale || {x:1, y:1}; let pixels_to_units = (node.SpriteRenderer.Sprite.PixelsToUnits) || 100.0; let pivot = node.SpriteRenderer.Sprite.Pivot || {x:0.5, y:0.5}; let scaled_w = img.width * scale.x; let scaled_h = img.height * scale.y; let color = node.SpriteRenderer.Color || {r:1,g:1,b:1,a:1}; let px = pos.x * pixels_to_units; let py = pos.y * pixels_to_units; let pivot_offset_x = pivot.x * scaled_w; let pivot_offset_y = pivot.y * scaled_h; let left = px - pivot_offset_x; let right = left + scaled_w; let top = py + (scaled_h - pivot_offset_y); let bottom = top - scaled_h; bboxes.push([left, top, right, bottom]); imagesInfo.push({ img, px, py, pivot_offset_x, pivot_offset_y, scaled_w, scaled_h, color, left, top, spriteName: node.SpriteRenderer.Sprite.Name, scale, node }); }); if (!imagesInfo.length) return; let min_x = Math.min(...bboxes.map(b=>b[0])); let max_x = Math.max(...bboxes.map(b=>b[2])); let min_y = Math.min(...bboxes.map(b=>b[3])); let max_y = Math.max(...bboxes.map(b=>b[1])); this.offscreenWidth = Math.ceil(max_x - min_x); this.offscreenHeight = Math.ceil(max_y - min_y); this.offscreenCanvas = document.createElement("canvas"); this.offscreenCanvas.width = this.offscreenWidth; this.offscreenCanvas.height = this.offscreenHeight; this.offscreenCtx = this.offscreenCanvas.getContext("2d"); this.offscreenCtx.clearRect(0, 0, this.offscreenWidth, this.offscreenHeight); // 清空 mask 缓存(每次合成新建或保留?保留可以重用;这里清空以避免孤立缓存) this.maskAlphaCache = {}; const t3 = performance.now(); console.log(`[composeAndDraw] 图片信息与bbox计算耗时: ${(t3-t2).toFixed(2)}ms`); const t4_start = performance.now(); imagesInfo.forEach((info, idx) => { const t_layer_start = performance.now(); let {img, px, py, pivot_offset_x, pivot_offset_y, scaled_w, scaled_h, color, left, top, scale, node} = info; // 临时 canvas 处理并缩放 const t_canvas_start = performance.now(); let tempCanvas = document.createElement("canvas"); tempCanvas.width = Math.max(1, Math.ceil(scaled_w)); tempCanvas.height = Math.max(1, Math.ceil(scaled_h)); let tempCtx = tempCanvas.getContext("2d"); tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); tempCtx.save(); // 为了避免二次放缩模糊,先 drawImage 原图到缩放后的大小 tempCtx.drawImage(img, 0, 0, tempCanvas.width, tempCanvas.height); tempCtx.restore(); const t_canvas_end = performance.now(); // 颜色应用(替代 getImageData,使用 source-in 技巧) const t_color_start = performance.now(); if (!(color.r === 1 && color.g === 1 && color.b === 1 && color.a === 1)) { let colorCanvas = document.createElement("canvas"); colorCanvas.width = tempCanvas.width; colorCanvas.height = tempCanvas.height; let colorCtx = colorCanvas.getContext("2d"); colorCtx.clearRect(0, 0, colorCanvas.width, colorCanvas.height); colorCtx.fillStyle = `rgba(${Math.round(color.r*255)}, ${Math.round(color.g*255)}, ${Math.round(color.b*255)}, ${color.a})`; colorCtx.fillRect(0, 0, colorCanvas.width, colorCanvas.height); colorCtx.globalCompositeOperation = "source-in"; colorCtx.drawImage(tempCanvas, 0, 0); tempCanvas = colorCanvas; } const t_color_end = performance.now(); let paste_x = Math.round(px - pivot_offset_x - min_x); let paste_y = Math.round(max_y - py - (scaled_h - pivot_offset_y)); // mask 处理 const t_mask_start = performance.now(); let floats = null; if (node.SpriteRenderer.Materials && node.SpriteRenderer.Materials.length > 0) { floats = node.SpriteRenderer.Materials[0].Floats || {}; } let stencilRef = floats && floats._StencilRef ? floats._StencilRef : 0; let stencilComp = floats && floats._StencilComp ? floats._StencilComp : 0; if (stencilRef !== 0 && stencilComp === 8) { // 被 mask 图层:记录到 mask 缓存并同时绘制到最终结果 let maskObj = this.ensureMaskCanvas(stencilRef); let maskCtx = maskObj.ctx; maskCtx.globalCompositeOperation = "source-over"; maskCtx.drawImage(tempCanvas, paste_x, paste_y); let materialName = (node.SpriteRenderer.Materials && node.SpriteRenderer.Materials[0].Name) || ""; this.offscreenCtx.globalCompositeOperation = this.getCompositeOp(materialName); this.offscreenCtx.globalAlpha = 1; this.offscreenCtx.drawImage(tempCanvas, paste_x, paste_y); this.offscreenCtx.globalCompositeOperation = "source-over"; } else if (stencilRef !== 0 && stencilComp === 4) { // 使用之前的 mask 缓存对本图层裁剪 let maskObj = this.maskAlphaCache[stencilRef]; if (maskObj) { let maskedCanvas = document.createElement("canvas"); maskedCanvas.width = tempCanvas.width; maskedCanvas.height = tempCanvas.height; let maskedCtx = maskedCanvas.getContext("2d"); // 把 mask 的对应区域绘制到 maskedCanvas maskedCtx.drawImage( maskObj.canvas, paste_x, paste_y, tempCanvas.width, tempCanvas.height, 0, 0, tempCanvas.width, tempCanvas.height ); // 用 source-in 将 tempCanvas 裁剪到 mask maskedCtx.globalCompositeOperation = "source-in"; maskedCtx.drawImage(tempCanvas, 0, 0); let materialName = (node.SpriteRenderer.Materials && node.SpriteRenderer.Materials[0].Name) || ""; this.offscreenCtx.globalCompositeOperation = this.getCompositeOp(materialName); this.offscreenCtx.globalAlpha = 1; this.offscreenCtx.drawImage(maskedCanvas, paste_x, paste_y); this.offscreenCtx.globalCompositeOperation = "source-over"; } } else { // 普通图层 let materialName = (node.SpriteRenderer.Materials && node.SpriteRenderer.Materials[0].Name) || ""; this.offscreenCtx.globalCompositeOperation = this.getCompositeOp(materialName); this.offscreenCtx.globalAlpha = 1; this.offscreenCtx.drawImage(tempCanvas, paste_x, paste_y); this.offscreenCtx.globalCompositeOperation = "source-over"; } const t_mask_end = performance.now(); const t_layer_end = performance.now(); console.log(`[composeAndDraw] 图层${idx}(${node.Name || ''})耗时: 总${(t_layer_end-t_layer_start).toFixed(2)}ms, ` + `canvas+缩放${(t_canvas_end-t_canvas_start).toFixed(2)}ms, ` + `颜色${(t_color_end-t_color_start).toFixed(2)}ms, ` + `mask/绘制${(t_mask_end-t_mask_start).toFixed(2)}ms`); }); const t4_end = performance.now(); console.log(`[composeAndDraw] 图层合成耗时: ${(t4_end-t4_start).toFixed(2)}ms`); // 合成完成后绘制到主画布(居中缩放) const t6_start = performance.now(); this.drawToMainCanvas(); const t6_end = performance.now(); console.log(`[composeAndDraw] 主画布绘制耗时: ${(t6_end-t6_start).toFixed(2)}ms`); console.log(`[composeAndDraw] 总耗时: ${(t6_end-t0).toFixed(2)}ms`); }); } saveImage() { if (!this.offscreenCanvas) return; let dataURL = this.offscreenCanvas.toDataURL("image/webp", 1.0); let a = document.createElement("a"); a.href = dataURL; a.download = `${this.app.currentCharacter}.webp`; a.click(); } // 新增:根据材质名返回合成模式 getCompositeOp(materialName) { if (!materialName) return "source-over"; if (materialName.startsWith("Naninovel_Multiply")) return "multiply"; if (materialName.startsWith("Naninovel_Softlight")) return "luminosity"; return "source-over"; } // 新增:确保 mask canvas 存在并返回对象 ensureMaskCanvas(stencilRef) { if (!this.maskAlphaCache[stencilRef]) { let maskCanvas = document.createElement("canvas"); maskCanvas.width = this.offscreenWidth; maskCanvas.height = this.offscreenHeight; let maskCtx = maskCanvas.getContext("2d"); maskCtx.clearRect(0, 0, this.offscreenWidth, this.offscreenHeight); this.maskAlphaCache[stencilRef] = {canvas: maskCanvas, ctx: maskCtx}; } return this.maskAlphaCache[stencilRef]; } } class App { constructor(rootNode, currentCharacter) { this.rootNode = rootNode; this.currentCharacter = currentCharacter || window.currentCharacter || "unknown"; // DOM this.parentSelect = document.getElementById("parentSelect"); this.childSelect = document.getElementById("childSelect"); this.canvas = document.getElementById("canvas"); this.ctx = this.canvas.getContext("2d"); this.saveBtn = document.getElementById("saveBtn"); // 视图状态(拖拽 & 缩放) this.view = { scale: 1, offsetX: 0, offsetY: 0, dragging: false, dragStartX: 0, dragStartY: 0, lastOffsetX: 0, lastOffsetY: 0 }; this.isSplitterDragging = false; // 子系统 this.selector = new TreeSelector(this); this.renderer = new Renderer(this); // Panzoom manager will enable touch-friendly pan/zoom using @panzoom/panzoom this.panzoom = null; // 事件与初始化 this.initEvents(); this.selector.render(this.rootNode); this.renderer.composeAndDraw(); // Initialize Panzoom if available if (window.Panzoom) { this.panzoom = new PanzoomManager(this); this.panzoom.init(); } // 初始化移动抽屉引用 this.mobileDrawer = document.getElementById('mobile-drawer'); this.drawerHandle = document.getElementById('drawer-handle'); this.drawerContent = document.getElementById('drawer-controls-content'); // 保证移动抽屉内按钮运行:绑定移动版按钮到现有功能 const saveMobile = document.getElementById('saveBtn_mobile'); if (saveMobile) saveMobile.onclick = () => this.renderer.saveImage(); const backMobile = document.getElementById('backBtn_mobile'); if (backMobile) backMobile.onclick = () => { window.location.href = '/'; }; const resetMobile = document.getElementById('resetBtn_mobile'); if (resetMobile) resetMobile.onclick = () => { this.selector.clearSelectionCache(); localStorage.removeItem(this.selector.getCollapseCacheKey()); window.__treeSelectorRendered = false; this.selector.render(this.rootNode); this.renderer.composeAndDraw(); }; // 绑定抽屉事件 this.setupDrawer(); // 根据视口决定 selector 渲染目标:移动端渲染到抽屉内,桌面渲染到 controls const isMobile = window.innerWidth <= 800 && this.drawerContent; if (isMobile) { // 替换 selector 的容器引用并渲染 this.selector.controlsContentDiv = this.drawerContent; this.selector.render(this.rootNode); // 自动展开抽屉(移动端直接展示选择器) if (this.openDrawer) this.openDrawer(); } else { this.selector.controlsContentDiv = document.getElementById('controls-content'); this.selector.render(this.rootNode); } // 暴露给外部(主脚本) window.app = this; } // 抽屉相关的初始化与事件绑定 setupDrawer() { if (!this.mobileDrawer || !this.drawerHandle) return; const drawer = this.mobileDrawer; const handle = this.drawerHandle; let startY = 0, startHeight = 0, dragging = false, touchMoved = false; // 将 open/close 方法暴露到实例上,便于外部调用(比如备用按钮) this.openDrawer = () => { drawer.style.display = 'block'; // 强制显示(覆盖 media query 仅用于调试/降级) drawer.classList.remove('drawer-closed'); drawer.classList.add('drawer-open'); drawer.setAttribute('aria-hidden', 'false'); }; this.closeDrawer = () => { drawer.classList.remove('drawer-open'); drawer.classList.add('drawer-closed'); drawer.setAttribute('aria-hidden', 'true'); // 恢复 display(如果媒体查询会隐藏的话) if (window.innerWidth > 800) drawer.style.display = ''; }; handle.addEventListener('click', (e) => { if (drawer.classList.contains('drawer-open')) this.closeDrawer(); else this.openDrawer(); }); // touch drag handle.addEventListener('touchstart', (e) => { dragging = true; touchMoved = false; startY = e.touches[0].clientY; startHeight = drawer.getBoundingClientRect().height; e.preventDefault(); }, { passive: false }); handle.addEventListener('touchmove', (e) => { if (!dragging) return; let dy = startY - e.touches[0].clientY; // 向上为正 // 如果移动超过阈值,视为拖动 if (Math.abs(dy) > 6) touchMoved = true; let newHeight = Math.max(48, Math.min(window.innerHeight * 0.9, startHeight + dy)); drawer.style.height = newHeight + 'px'; e.preventDefault(); }, { passive: false }); handle.addEventListener('touchend', (e) => { dragging = false; // 如果是轻触(无明显移动),则视为 tap,切换抽屉 if (!touchMoved) { if (drawer.classList.contains('drawer-open')) this.closeDrawer(); else this.openDrawer(); } else { let rect = drawer.getBoundingClientRect(); // 如果高度超过屏幕的一半则打开,否则关闭 if (rect.height > window.innerHeight * 0.4) this.openDrawer(); else this.closeDrawer(); } drawer.style.height = ''; e.preventDefault(); }, { passive: false }); // 不使用按钮打开抽屉(遵循用户要求),保留 openDrawer/closeDrawer 方法供内部调用 } // 新增:从容器设置 canvas 尺寸 setCanvasSizeFromContainer() { const canvasContainer = document.getElementById("canvas-container"); if (!canvasContainer) return; const rect = canvasContainer.getBoundingClientRect(); this.canvas.width = rect.width; this.canvas.height = rect.height; } // 新增:统一绑定按钮 attachButton(id, handler) { const el = document.getElementById(id); if (el) el.onclick = handler; } initEvents() { // 拖拽 this.canvas.addEventListener("mousedown", (e) => { this.view.dragging = true; this.view.dragStartX = e.clientX; this.view.dragStartY = e.clientY; this.view.lastOffsetX = this.view.offsetX; this.view.lastOffsetY = this.view.offsetY; }); window.addEventListener("mousemove", (e) => { if (this.view.dragging) { this.view.offsetX = this.view.lastOffsetX + (e.clientX - this.view.dragStartX); this.view.offsetY = this.view.lastOffsetY + (e.clientY - this.view.dragStartY); this.renderer.drawToMainCanvas(); } }); window.addEventListener("mouseup", () => { this.view.dragging = false; this.renderer.drawToMainCanvas(); }); // 滚轮缩放 this.canvas.addEventListener("wheel", (e) => { // If panzoom is active, let it handle wheel events if (this.panzoom && this.panzoom.isActive) return; e.preventDefault(); let oldScale = this.view.scale; if (e.deltaY < 0) this.view.scale *= 1.1; else this.view.scale /= 1.1; let rect = this.canvas.getBoundingClientRect(); let mx = e.clientX - rect.left; let my = e.clientY - rect.top; this.view.offsetX = mx - (mx - this.view.offsetX) * (this.view.scale / oldScale); this.view.offsetY = my - (my - this.view.offsetY) * (this.view.scale / oldScale); this.renderer.drawToMainCanvas(); }, { passive: false }); // 窗口大小自适应(非分割线拖动时) window.addEventListener("resize", () => { if (this.isSplitterDragging) return; this.setCanvasSizeFromContainer(); this.renderer.drawToMainCanvas(); }); // 立即触发一次大小调整 this.setCanvasSizeFromContainer(); // 分割线拖动(保留原有优化) (function(that){ const splitter = document.getElementById("splitter"); const controls = document.getElementById("controls"); if (!splitter || !controls) return; let dragging = false; let lastWidth = null; let animationFrameId = null; function updateWidth(e) { const layoutRect = document.getElementById("main-layout").getBoundingClientRect(); let newWidth = e.clientX - layoutRect.left; newWidth = Math.max(200, Math.min(newWidth, 800)); if (lastWidth !== newWidth) { controls.style.flexBasis = newWidth + "px"; lastWidth = newWidth; that.isSplitterDragging = true; that.setCanvasSizeFromContainer(); that.renderer.drawToMainCanvas(); } } splitter.addEventListener("mousedown", function(e) { dragging = true; that.isSplitterDragging = true; document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; }); window.addEventListener("mousemove", function(e) { if (!dragging) return; if (animationFrameId) return; animationFrameId = requestAnimationFrame(() => { updateWidth(e); animationFrameId = null; }); }); window.addEventListener("mouseup", function() { if (dragging) { dragging = false; that.isSplitterDragging = false; document.body.style.cursor = ""; document.body.style.userSelect = ""; animationFrameId = null; } }); })(this); // 按钮事件:保存/返回/重置 this.attachButton("saveBtn", () => this.renderer.saveImage()); this.attachButton("backBtn", () => { window.location.href = '/'; }); this.attachButton("resetBtn", () => { this.selector.clearSelectionCache(); localStorage.removeItem(this.selector.getCollapseCacheKey()); window.__treeSelectorRendered = false; this.selector.render(this.rootNode); this.renderer.composeAndDraw(); }); // Drawer 交互:手柄点击/拖拽打开/关闭抽屉(仅在移动端) // 抽屉事件在构造函数中 setupDrawer() 里绑定(确保 DOM 已存在) // 触摸事件支持:单指平移,双指捏合缩放 let lastTouchDist = 0; let lastTouchCenter = null; let gestureMode = null; // "pan" 或 "zoom" this.canvas.addEventListener('touchstart', (e) => { if (this.panzoom && this.panzoom.isActive) return; if (e.touches.length === 1) { gestureMode = "pan"; const t = e.touches[0]; this.view.dragging = true; this.view.dragStartX = t.clientX; this.view.dragStartY = t.clientY; this.view.lastOffsetX = this.view.offsetX; this.view.lastOffsetY = this.view.offsetY; } else if (e.touches.length === 2) { gestureMode = "zoom"; // 初始化捏合 const t0 = e.touches[0], t1 = e.touches[1]; lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); lastTouchCenter = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 }; } // 阻止页面滚动以获得更好的交互 e.preventDefault(); }, { passive: false }); this.canvas.addEventListener('touchmove', (e) => { if (this.panzoom && this.panzoom.isActive) return; if (gestureMode === "pan" && e.touches.length === 1 && this.view.dragging) { const t = e.touches[0]; this.view.offsetX = this.view.lastOffsetX + (t.clientX - this.view.dragStartX); this.view.offsetY = this.view.lastOffsetY + (t.clientY - this.view.dragStartY); this.renderer.drawToMainCanvas(); } else if (gestureMode === "zoom" && e.touches.length === 2) { const t0 = e.touches[0], t1 = e.touches[1]; const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); const center = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 }; if (lastTouchDist > 0) { let oldScale = this.view.scale; let factor = dist / lastTouchDist; this.view.scale = Math.max(0.1, Math.min(5, this.view.scale * factor)); // 根据中心点调整偏移以实现以双指中心为缩放锚点 let rect = this.canvas.getBoundingClientRect(); let mx = center.x - rect.left; let my = center.y - rect.top; this.view.offsetX = mx - (mx - this.view.offsetX) * (this.view.scale / oldScale); this.view.offsetY = my - (my - this.view.offsetY) * (this.view.scale / oldScale); this.renderer.drawToMainCanvas(); } lastTouchDist = dist; lastTouchCenter = center; } e.preventDefault(); }, { passive: false }); this.canvas.addEventListener('touchend', (e) => { if (e.touches.length === 0) { this.view.dragging = false; lastTouchDist = 0; lastTouchCenter = null; gestureMode = null; // 重置模式 } e.preventDefault(); }); } } const app = new App(rootNode, currentCharacter);