import { announce, apiJson } from "../core.js"; import { runControlAction, setControlReady, setControlUnavailable } from "../interaction-feedback.js"; import { layerToStorage, sceneToStorageValue } from "../scene-model.js"; import { getScene, getSelectedLayer, subscribeScene, selectLayer, changeLayer, setLayerBackground, animationEditState, templateEditState, enterTemplateEdit, enterAnimationEdit, markTemplateSaved, markAnimationFrameSaved } from "../scene-board.js"; import { initializeTextWorkspace } from "./text.js"; import { refreshTemplates } from "./templates.js"; import { refreshAnimations } from "./animations.js"; const byId=id=>document.getElementById(id); const list=byId("layer-list"), background=byId("layer-background-enabled"), color=byId("background-color"); const dialog=byId("layer-copy-dialog"),target=byId("layer-copy-target"),feedback=byId("layer-copy-feedback"); const submit=byId("layer-copy-submit"),frames=byId("layer-copy-frame-list"); let source=null,targets=new Map(),targetDetail=null,loadSequence=0,busy=false; function render({scene}) { const selected=getSelectedLayer(),brush=selected.type==="brush"; byId("brush-layer-tools").hidden=!brush;byId("text-layer-tools").hidden=brush; background.checked=Boolean(selected.background); if(brush){color.value=selected.backgroundColor||selected.background||"#000000";color.dispatchEvent(new Event("input"));} list.replaceChildren(...[...scene.layers].reverse().map(l=>{ const button=document.createElement("button");button.type="button";button.className="layer-item"; button.textContent=`${l.name} · ${l.type==="brush"?"画笔":"文字"}`;button.setAttribute("aria-pressed",String(l.id===selected.id)); button.addEventListener("click",()=>selectLayer(l.id));return button; })); const index=scene.layers.indexOf(selected); for(const [id,allowed,reason] of [["delete-layer",!brush||scene.layers.filter(l=>l.type==="brush").length>1,"必须保留一个画笔图层。"], ["raise-layer",index0,"已经在最下方。"]]) { if(allowed)setControlReady(byId(id));else setControlUnavailable(byId(id),reason); } if(!brush)initializeTextWorkspace().catch(e=>announce(e.message,true)); } subscribeScene(render); for(const [id,action,value] of [["new-brush-layer","create","brush"],["new-text-layer","create","text"], ["delete-layer","delete"],["raise-layer","up"],["lower-layer","down"]])byId(id).addEventListener("click",()=>changeLayer(action,value)); byId("rename-layer").addEventListener("click",()=>{ const name=window.prompt("图层名称(1..80个字符)",getSelectedLayer().name); if(name!==null)try{changeLayer("rename",name);}catch(e){announce(e.message,true);} }); background.addEventListener("change",()=>setLayerBackground(background.checked,color.value)); color.addEventListener("input",()=>{if(getSelectedLayer().type==="brush"&&getSelectedLayer().backgroundColor!==color.value)setLayerBackground(background.checked,color.value);}); function dirtyChoice(){ const d=document.createElement("dialog");d.className="layer-copy-dialog"; const title=document.createElement("h3");title.textContent="目标画面正在编辑,存在未保存修改";d.append(title); for(const [value,label] of [["save","保存后复制"],["discard","放弃修改后复制"],["cancel","取消"]]){ const b=document.createElement("button");b.type="button";b.textContent=label;b.addEventListener("click",()=>d.close(value));d.append(b); } document.body.append(d);d.showModal();return new Promise(resolve=>d.addEventListener("close",()=>{const choice=d.returnValue||"cancel";d.remove();resolve(choice);},{once:true})); } async function loadFrames(){ const sequence=++loadSequence;targetDetail=null;frames.replaceChildren();byId("layer-copy-frames").hidden=true; const ref=targets.get(target.value); if(!ref){setControlReady(submit);return;} setControlUnavailable(submit,"正在读取目标帧。");feedback.textContent="正在读取目标帧…"; try{ const detail=await apiJson(ref.type==="template"?`/api/templates/${ref.id}`:`/api/animations/${ref.id}/frames`);if(sequence!==loadSequence)return; targetDetail=detail; if(ref.type==="template"){feedback.textContent="副本默认置顶。";setControlReady(submit);return;} byId("layer-copy-frames").hidden=false; for(const [i,frame] of detail.frames.entries()) { const label=document.createElement("label"),input=document.createElement("input");input.type="checkbox";input.value=frame.id; const name=document.createElement("span");name.textContent=`${i+1}. ${frame.name||"未命名帧"}`; label.append(input,name);frames.append(label); } feedback.textContent=detail.frames.length?"选择一个或多个目标帧,复制后置顶。":"目标动图没有帧,请先添加帧。"; if(detail.frames.length)setControlReady(submit);else setControlUnavailable(submit,"目标动图没有帧。"); }catch(e){if(sequence===loadSequence){feedback.textContent=e.message;setControlUnavailable(submit,"读取失败,请重新选择目标。");}} } byId("copy-layer").addEventListener("click",async()=>{ const sequence=++loadSequence; source=layerToStorage(getSelectedLayer());targets=new Map();target.replaceChildren(new Option("当前画板","current")); feedback.textContent="正在读取可复制目标…";dialog.showModal();setControlUnavailable(submit,"正在读取目标。"); try{ const [templates,animations]=await Promise.all([apiJson("/api/templates"),apiJson("/api/animations")]); if(sequence!==loadSequence)return; for(const [type,items] of [["template",templates.templates],["animation",animations.animations]])for(const item of items.filter(i=>!i.is_demo&&!i.read_only)){ const key=`${type}:${item.id}`;targets.set(key,{...item,type});target.append(new Option(`${type==="template"?"静态":"动图"} · ${item.name}`,key)); } feedback.textContent="副本默认置顶,不改变原图层。";await loadFrames(); }catch(e){feedback.textContent=e.message;setControlReady(submit);} }); target.addEventListener("change",loadFrames); for(const [id,checked] of [["layer-copy-all",true],["layer-copy-none",false]])byId(id).addEventListener("click",()=>frames.querySelectorAll("input").forEach(input=>input.checked=checked)); byId("layer-copy-cancel").addEventListener("click",()=>{if(!busy){loadSequence++;dialog.close();}}); dialog.addEventListener("cancel",e=>{if(busy)e.preventDefault();else loadSequence++;}); submit.addEventListener("click",()=>runControlAction(submit,{message:"正在复制图层…"},async()=>{ busy=true;target.disabled=true;frames.inert=true; try{ if(target.value==="current"){changeLayer("duplicate");dialog.close();announce("图层已复制到当前画板最上方");return;} const ref=targets.get(target.value);if(!ref)throw Error("请选择有效目标"); const edit=ref.type==="template"?templateEditState():animationEditState(); // An ordinary template parked behind animation editing is not the active board. const editing=edit&&(ref.type==="template"?edit.id===ref.id&&!animationEditState():edit.animationId===ref.id); let savedRevision=null; if(editing&&edit.dirty){ const choice=await dirtyChoice();if(choice==="cancel")return; if(choice==="save"){ const path=ref.type==="template"?`/api/templates/${ref.id}`:`/api/animations/${ref.id}/frames/${edit.frameId}`; const saved=await apiJson(path,{method:"PUT",headers:{"If-Match":`"${edit.revision}"`},body:JSON.stringify({scene:sceneToStorageValue(getScene())})}); if(ref.type==="template")markTemplateSaved(saved);else markAnimationFrameSaved(saved); savedRevision=saved.revision; } } const current=targetDetail; if(!current)throw Error("目标状态已失效,请重新选择"); // Preserve the revision from frame selection: intervening writes must conflict. const frameIds=Array.from(frames.querySelectorAll("input:checked"),input=>input.value); if(ref.type==="animation"&&!frameIds.length)throw Error("请至少选择一个目标帧"); const revision=savedRevision||current.revision; const result=await apiJson(ref.type==="template"?`/api/templates/${ref.id}/layers`:`/api/animations/${ref.id}/frames/layers`,{ method:"POST",headers:{"If-Match":`"${revision}"`},body:JSON.stringify({layer:source,...(ref.type==="animation"?{frame_ids:frameIds}:{})})}); if(editing){if(ref.type==="template")enterTemplateEdit(result);else{ const frame=await apiJson(`/api/animations/${ref.id}/frames/${edit.frameId}`); enterAnimationEdit({...frame,animation_id:ref.id,animation_name:result.name,animation_revision:result.revision}); }} dialog.close();announce("图层已复制到目标最上方"); await Promise.all([refreshTemplates(),refreshAnimations()]); }catch(e){feedback.textContent=`复制失败:${e.message}`;announce(feedback.textContent,true);} finally{busy=false;target.disabled=false;frames.inert=false;} }));