返回 ppt-master
app.js
1 /* ============================================================
2 PPT Master - SVG Editor | app.js
3 Vanilla JS, IIFE pattern
4 ============================================================ */
5 (function () {
6 "use strict";
7
8 // ---- i18n -------------------------------------------------------
9 var MESSAGES = {
10 en: {
11 page_title: "PPT Master - Live Preview",
12 panel_slides: "Slides",
13 panel_annotations: "Annotations",
14 panel_edit_annotate: "Edit / Annotate",
15 placeholder_select_slide: "Select a slide on the left to begin",
16 label_selected_element: "Selected element",
17 empty_selected_element: "Click an element on the slide to select it",
18 btn_select_group: "Select parent group",
19 label_batch_edit: "Batch edit",
20 label_group_edit: "Group edit",
21 section_geometry: "Geometry",
22 section_style: "Style",
23 section_text_style: "Text",
24 section_raw_attrs: "Raw attributes",
25 label_edit_instruction: "Edit instruction",
26 pending_none: "No pending changes",
27 pending_summary: "{edits} direct edit(s), {annotations} annotation page(s) pending",
28 pending_pages: "Pages: {pages}",
29 quick_align: "Align / move",
30 quick_resize: "Resize",
31 quick_replace_image: "Replace image",
32 quick_copy: "Revise copy",
33 quick_relayout: "Relayout area",
34 placeholder_annotation: "Describe how the AI should modify this element...",
35 placeholder_annotation_multi: "Describe how to modify all {count} elements...",
36 btn_add_annotation: "Add annotation",
37 label_annotations_on_slide: "Annotations on this slide",
38 btn_submit_annotations: "Apply changes",
39 btn_exit_preview: "Exit preview",
40 modal_submit: "Submit",
41 modal_cancel: "Cancel",
42 empty_waiting_slides: "Waiting for generated slides...",
43 empty_no_slides: "No slides found",
44 placeholder_live_ready: "Live preview is ready. Generated slides will appear here.",
45 placeholder_slide_writing: "Slide is still being written. Waiting for the next refresh...",
46 empty_annotations: "No annotations yet",
47 tooltip_remove_annotation: "Remove annotation",
48 multi_selected: "{count} elements selected",
49 multi_mixed: "mixed",
50 err_load_slides: "Failed to load slides: ",
51 err_load_slide: "Failed to load slide: ",
52 err_add_annotation: "Failed to add annotation: ",
53 err_remove_annotation: "Failed to remove annotation: ",
54 err_save: "Save failed: ",
55 err_edit: "Edit failed: ",
56 label_direct_edit: "Object attributes (pending until Apply changes)",
57 prop_multiline_hint: "Multi-line — select a single line (tspan) to edit text",
58 edit_saved_hint: "Change staged. Click Apply changes to write it to svg_output.",
59 btn_undo: "Undo",
60 undo_done: "Reverted last staged edit",
61 undo_empty: "No staged edit to undo",
62 overlap_caption: "Overlapping here — pick one",
63 err_empty_svg: "Slide loaded but the canvas is empty. The SVG may be malformed or missing a root <svg> element.",
64 warn_icon_inline: "{count} icon(s) failed to render: {names}",
65 warn_matrix_transform: "This geometry edit is stored as a transform matrix. Preview is exact; PPTX export depends on matrix-aware conversion.",
66 modal_matrix_transform_note: "\n\nNote: at least one staged geometry edit uses a transform matrix. Re-export with the current PPTX exporter so the matrix is applied.",
67 slide_error_tooltip: "Failed to parse this slide: ",
68 reload_banner: "This slide was updated on disk. Click to reload.",
69 modal_confirm_submit: "Apply staged direct edits and AI annotations to disk?\n\nThe preview service will keep running. Click Exit preview when you want to stop it.",
70 modal_success_submit: "Changes saved to svg_output.\n\nThe preview service is still running.",
71 modal_success_direct_only: "Changes saved to svg_output.\n\nDirect edits are now in the SVG source. Return to the chat and ask to re-export when you want a refreshed PPTX. The preview service is still running.",
72 modal_success_annotations_only: "Annotations saved to svg_output.\n\nReturn to the chat and ask to apply annotations when you want AI to interpret them. The preview service is still running.",
73 modal_success_mixed: "Direct edits and annotations saved to svg_output.\n\nReturn to the chat to apply AI-needed annotations, then re-export the PPTX when ready. The preview service is still running.",
74 modal_confirm_exit: "Exit preview and stop the local server?\n\nUnapplied edits and annotations will be discarded.",
75 modal_success_exit: "Preview stopped.\n\nYou can close this tab and return to the chat.",
76 modal_stopping: "Stopping preview server...",
77 lang_toggle_title: "Switch language",
78 nav_first: "First slide (Home)",
79 nav_prev: "Previous slide (←)",
80 nav_next: "Next slide (→)",
81 nav_last: "Last slide (End)",
82 nav_counter: "{current} / {total}",
83 nav_empty: "— / —"
84 },
85 ja: {
86 page_title: "PPT Master - ライブプレビュー",
87 panel_slides: "スライド",
88 panel_annotations: "注釈",
89 panel_edit_annotate: "編集 / 注釈",
90 placeholder_select_slide: "左のスライドを選択して開始",
91 label_selected_element: "選択中の要素",
92 empty_selected_element: "スライド上の要素をクリックして選択",
93 btn_select_group: "親グループを選択",
94 label_batch_edit: "一括編集",
95 label_group_edit: "グループ編集",
96 section_geometry: "位置・サイズ",
97 section_style: "スタイル",
98 section_text_style: "テキスト",
99 section_raw_attrs: "生の属性",
100 label_edit_instruction: "編集指示",
101 pending_none: "未適用の変更はありません",
102 pending_summary: "直接編集{edits}件、AI注釈のあるページ{annotations}件が未適用",
103 pending_pages: "対象ページ:{pages}",
104 quick_align: "整列 / 移動",
105 quick_resize: "サイズ変更",
106 quick_replace_image: "画像を差し替え",
107 quick_copy: "文言を修正",
108 quick_relayout: "この領域を再レイアウト",
109 placeholder_annotation: "この要素をAIにどう修正してほしいか記述…",
110 placeholder_annotation_multi: "選択した{count}個の要素をどう修正するか記述…",
111 btn_add_annotation: "注釈を追加",
112 label_annotations_on_slide: "このスライドの注釈",
113 btn_submit_annotations: "変更を適用",
114 btn_exit_preview: "プレビューを終了",
115 modal_submit: "送信",
116 modal_cancel: "キャンセル",
117 empty_waiting_slides: "スライドの生成を待っています…",
118 empty_no_slides: "スライドが見つかりません",
119 placeholder_live_ready: "ライブプレビュー準備完了。生成されたスライドがここに表示されます。",
120 placeholder_slide_writing: "スライドはまだ書き込み中です。次の更新を待っています…",
121 empty_annotations: "注釈はまだありません",
122 tooltip_remove_annotation: "注釈を削除",
123 multi_selected: "{count}個の要素を選択中",
124 multi_mixed: "混在",
125 err_load_slides: "スライド一覧の読み込みに失敗: ",
126 err_load_slide: "スライドの読み込みに失敗: ",
127 err_add_annotation: "注釈の追加に失敗: ",
128 err_remove_annotation: "注釈の削除に失敗: ",
129 err_save: "保存に失敗: ",
130 err_edit: "編集に失敗: ",
131 label_direct_edit: "オブジェクト属性(「変更を適用」までは保留)",
132 prop_multiline_hint: "複数行テキストです — 文字を編集するには1行(tspan)を選択してください",
133 edit_saved_hint: "変更を一時保存しました。「変更を適用」で svg_output に書き込まれます。",
134 btn_undo: "元に戻す",
135 undo_done: "直前の一時保存済み編集を取り消しました",
136 undo_empty: "取り消せる編集はありません",
137 overlap_caption: "要素が重なっています — 1つ選択してください",
138 err_empty_svg: "スライドは読み込めましたがキャンバスが空です。SVGが不正か、ルート<svg>要素がない可能性があります。",
139 warn_icon_inline: "{count}個のアイコンを描画できませんでした: {names}",
140 warn_matrix_transform: "このジオメトリ編集は transform matrix として保存されます。プレビューは正確ですが、PPTX出力にはmatrix対応の現行エクスポーターが必要です。",
141 modal_matrix_transform_note: "\n\n注意:一時保存済みのジオメトリ編集に transform matrix を使うものがあります。matrixが反映されるよう、現行のPPTXエクスポーターで再エクスポートしてください。",
142 slide_error_tooltip: "このスライドの解析に失敗: ",
143 reload_banner: "このスライドはディスク上で更新されました。クリックで再読み込み。",
144 modal_confirm_submit: "一時保存済みの直接編集とAI注釈をディスクに書き込みますか?\n\nプレビューサービスは動き続けます。止めたいときは「プレビューを終了」を押してください。",
145 modal_success_submit: "変更を svg_output に保存しました。\n\nプレビューサービスは引き続き動作中です。",
146 modal_success_direct_only: "変更を svg_output に保存しました。\n\n直接編集はSVGソースに反映済みです。PPTXを更新したいときは、チャットに戻って再エクスポートを依頼してください。プレビューサービスは引き続き動作中です。",
147 modal_success_annotations_only: "注釈を svg_output に保存しました。\n\nAIに注釈を解釈・反映させたいときは、チャットに戻って注釈の適用を依頼してください。プレビューサービスは引き続き動作中です。",
148 modal_success_mixed: "直接編集と注釈を svg_output に保存しました。\n\nチャットに戻ってAI判断が必要な注釈の適用を先に依頼し、確認できたらPPTXを再エクスポートしてください。プレビューサービスは引き続き動作中です。",
149 modal_confirm_exit: "プレビューを終了してローカルサーバーを停止しますか?\n\n未適用の編集と注釈は破棄されます。",
150 modal_success_exit: "プレビューを停止しました。\n\nこのタブを閉じてチャットに戻れます。",
151 modal_stopping: "プレビューサーバーを停止しています…",
152 lang_toggle_title: "言語を切り替え",
153 nav_first: "最初のスライド (Home)",
154 nav_prev: "前のスライド (←)",
155 nav_next: "次のスライド (→)",
156 nav_last: "最後のスライド (End)",
157 nav_counter: "{current} / {total}",
158 nav_empty: "— / —"
159 },
160 zh: {
161 page_title: "PPT Master - 实时预览",
162 panel_slides: "幻灯片",
163 panel_annotations: "标注",
164 panel_edit_annotate: "编辑 / 标注",
165 placeholder_select_slide: "在左侧选择一张幻灯片开始",
166 label_selected_element: "已选元素",
167 empty_selected_element: "点击幻灯片中的元素进行选择",
168 btn_select_group: "选择父级组",
169 label_batch_edit: "批量编辑",
170 label_group_edit: "组编辑",
171 section_geometry: "几何",
172 section_style: "样式",
173 section_text_style: "文本",
174 section_raw_attrs: "原始属性",
175 label_edit_instruction: "修改说明",
176 pending_none: "没有待应用修改",
177 pending_summary: "{edits} 条直接修改、{annotations} 个页面有 AI 标注待应用",
178 pending_pages: "页面:{pages}",
179 quick_align: "对齐/移动",
180 quick_resize: "调整大小",
181 quick_replace_image: "换图",
182 quick_copy: "改文案",
183 quick_relayout: "重排此区域",
184 placeholder_annotation: "描述希望 AI 如何修改该元素……",
185 placeholder_annotation_multi: "描述希望如何修改所选 {count} 个元素……",
186 btn_add_annotation: "添加标注",
187 label_annotations_on_slide: "本页标注",
188 btn_submit_annotations: "应用修改",
189 btn_exit_preview: "退出预览",
190 modal_submit: "提交",
191 modal_cancel: "取消",
192 empty_waiting_slides: "正在等待生成幻灯片……",
193 empty_no_slides: "未找到幻灯片",
194 placeholder_live_ready: "实时预览已就绪,生成的幻灯片会在这里出现。",
195 placeholder_slide_writing: "幻灯片仍在写入,等待下次刷新……",
196 empty_annotations: "暂无标注",
197 tooltip_remove_annotation: "删除标注",
198 multi_selected: "已选 {count} 个元素",
199 multi_mixed: "混合",
200 err_load_slides: "加载幻灯片失败:",
201 err_load_slide: "加载幻灯片失败:",
202 err_add_annotation: "添加标注失败:",
203 err_remove_annotation: "删除标注失败:",
204 err_save: "保存失败:",
205 err_edit: "编辑失败:",
206 label_direct_edit: "对象属性(点击应用修改后写入)",
207 prop_multiline_hint: "多行文本——选中单行(tspan)编辑文字",
208 edit_saved_hint: "修改已暂存。点击“应用修改”后写入 svg_output。",
209 btn_undo: "撤销",
210 undo_done: "已撤销上一条暂存修改",
211 undo_empty: "没有可撤销的暂存修改",
212 overlap_caption: "此处重叠元素——点击选择",
213 err_empty_svg: "幻灯片已加载但画布为空。SVG 可能损坏或缺少根 <svg> 元素。",
214 warn_icon_inline: "{count} 个图标渲染失败:{names}",
215 warn_matrix_transform: "本次几何修改会以 transform matrix 保存。预览是准确的;PPTX 导出需要使用支持 matrix 的当前导出器。",
216 modal_matrix_transform_note: "\n\n提示:至少有一条暂存几何修改使用了 transform matrix。请用当前 PPTX 导出器重新导出,确保 matrix 被应用。",
217 slide_error_tooltip: "该幻灯片解析失败:",
218 reload_banner: "当前页已在磁盘上更新,点此重新加载。",
219 modal_confirm_submit: "确认将暂存的直接修改和 AI 标注写入磁盘?\n\n预览服务会继续运行。需要关闭时请点击退出预览。",
220 modal_success_submit: "修改已保存到 svg_output。\n\n预览服务仍在运行。",
221 modal_success_direct_only: "修改已保存到 svg_output。\n\n直接修改已经写入 SVG 源文件;需要刷新 PPTX 时,请回到对话窗口要求重新导出。预览服务仍在运行。",
222 modal_success_annotations_only: "标注已保存到 svg_output。\n\n需要 AI 理解并执行这些标注时,请回到对话窗口要求应用标注。预览服务仍在运行。",
223 modal_success_mixed: "直接修改和标注已保存到 svg_output。\n\n请回到对话窗口先应用需要 AI 判断的标注,确认后再重新导出 PPTX。预览服务仍在运行。",
224 modal_confirm_exit: "退出预览并停止本地服务?\n\n未应用的属性修改和标注将被丢弃。",
225 modal_success_exit: "预览已停止。\n\n可以关闭本标签页并回到对话窗口。",
226 modal_stopping: "正在停止预览服务……",
227 lang_toggle_title: "切换语言",
228 nav_first: "第一页 (Home)",
229 nav_prev: "上一页 (←)",
230 nav_next: "下一页 (→)",
231 nav_last: "末页 (End)",
232 nav_counter: "{current} / {total}",
233 nav_empty: "— / —"
234 },
235 "zh-TW": {
236 page_title: "PPT Master - 即時預覽",
237 panel_slides: "投影片",
238 panel_annotations: "標註",
239 panel_edit_annotate: "編輯 / 標註",
240 placeholder_select_slide: "在左側選擇一張投影片開始",
241 label_selected_element: "已選元素",
242 empty_selected_element: "點選投影片中的元素進行選擇",
243 btn_select_group: "選擇父群組",
244 label_batch_edit: "批次編輯",
245 label_group_edit: "群組編輯",
246 section_geometry: "幾何",
247 section_style: "樣式",
248 section_text_style: "文字",
249 section_raw_attrs: "原始屬性",
250 label_edit_instruction: "修改說明",
251 pending_none: "沒有待套用修改",
252 pending_summary: "{edits} 筆直接修改、{annotations} 個頁面有 AI 標註待套用",
253 pending_pages: "頁面:{pages}",
254 quick_align: "對齊/移動",
255 quick_resize: "調整大小",
256 quick_replace_image: "換圖",
257 quick_copy: "改文案",
258 quick_relayout: "重排此區域",
259 placeholder_annotation: "描述希望 AI 如何修改該元素……",
260 placeholder_annotation_multi: "描述希望如何修改所選 {count} 個元素……",
261 btn_add_annotation: "新增標註",
262 label_annotations_on_slide: "本頁標註",
263 btn_submit_annotations: "套用修改",
264 btn_exit_preview: "結束預覽",
265 modal_submit: "提交",
266 modal_cancel: "取消",
267 empty_waiting_slides: "正在等待生成投影片……",
268 empty_no_slides: "未找到投影片",
269 placeholder_live_ready: "即時預覽已就緒,生成的投影片會在這裡出現。",
270 placeholder_slide_writing: "投影片仍在寫入,等待下次重新整理……",
271 empty_annotations: "尚無標註",
272 tooltip_remove_annotation: "刪除標註",
273 multi_selected: "已選 {count} 個元素",
274 multi_mixed: "混合",
275 err_load_slides: "載入投影片失敗:",
276 err_load_slide: "載入投影片失敗:",
277 err_add_annotation: "新增標註失敗:",
278 err_remove_annotation: "刪除標註失敗:",
279 err_save: "儲存失敗:",
280 err_edit: "編輯失敗:",
281 label_direct_edit: "物件屬性(點選套用修改後寫入)",
282 prop_multiline_hint: "多行文字——選取單行(tspan)編輯文字",
283 edit_saved_hint: "修改已暫存。點選「套用修改」後寫入 svg_output。",
284 btn_undo: "復原",
285 undo_done: "已復原上一筆暫存修改",
286 undo_empty: "沒有可復原的暫存修改",
287 overlap_caption: "此處元素重疊——請點選其中一個",
288 err_empty_svg: "投影片已載入但畫布為空。SVG 可能損壞或缺少根 <svg> 元素。",
289 warn_icon_inline: "{count} 個圖示渲染失敗:{names}",
290 warn_matrix_transform: "本次幾何修改會以 transform matrix 儲存。預覽是準確的;PPTX 匯出需要使用支援 matrix 的目前匯出器。",
291 modal_matrix_transform_note: "\n\n提示:至少有一筆暫存幾何修改使用了 transform matrix。請用目前 PPTX 匯出器重新匯出,確保 matrix 被套用。",
292 slide_error_tooltip: "該投影片解析失敗:",
293 reload_banner: "目前頁已在磁碟上更新,點此重新載入。",
294 modal_confirm_submit: "確認將暫存的直接修改和 AI 標註寫入磁碟?\n\n預覽服務會繼續執行。需要關閉時請點選結束預覽。",
295 modal_success_submit: "修改已儲存到 svg_output。\n\n預覽服務仍在執行。",
296 modal_success_direct_only: "修改已儲存到 svg_output。\n\n直接修改已經寫入 SVG 原始檔;需要重新整理 PPTX 時,請回到對話視窗要求重新匯出。預覽服務仍在執行。",
297 modal_success_annotations_only: "標註已儲存到 svg_output。\n\n需要 AI 理解並執行這些標註時,請回到對話視窗要求套用標註。預覽服務仍在執行。",
298 modal_success_mixed: "直接修改和標註已儲存到 svg_output。\n\n請回到對話視窗先套用需要 AI 判斷的標註,確認後再重新匯出 PPTX。預覽服務仍在執行。",
299 modal_confirm_exit: "結束預覽並停止本機服務?\n\n未套用的屬性修改和標註將被丟棄。",
300 modal_success_exit: "預覽已停止。\n\n可以關閉本分頁並回到對話視窗。",
301 modal_stopping: "正在停止預覽服務……",
302 lang_toggle_title: "切換語言",
303 nav_first: "第一頁 (Home)",
304 nav_prev: "上一頁 (←)",
305 nav_next: "下一頁 (→)",
306 nav_last: "末頁 (End)",
307 nav_counter: "{current} / {total}",
308 nav_empty: "— / —"
309 }
310 };
311
312 var LANG = (function () {
313 try {
314 var stored = window.localStorage.getItem("ppt_lang");
315 if (stored === "zh" || stored === "en" || stored === "ja" ||
316 stored === "zh-TW") return stored;
317 } catch (e) { /* ignore */ }
318 var nav = (navigator.language || navigator.userLanguage || "en").toLowerCase();
319 if (nav.indexOf("zh") === 0) {
320 if (/\bhans\b/.test(nav)) return "zh";
321 if (/\bhant\b/.test(nav) || /\b(tw|hk|mo)\b/.test(nav)) return "zh-TW";
322 return "zh";
323 }
324 if (nav.indexOf("ja") === 0) return "ja";
325 return "en";
326 })();
327
328 function t(key, params) {
329 var dict = MESSAGES[LANG] || MESSAGES.en;
330 var msg = dict[key];
331 if (msg === undefined) msg = MESSAGES.en[key];
332 if (msg === undefined) return key;
333 if (params) {
334 Object.keys(params).forEach(function (p) {
335 msg = msg.replace("{" + p + "}", params[p]);
336 });
337 }
338 return msg;
339 }
340
341 function applyI18n() {
342 document.documentElement.setAttribute("lang", LANG === "zh" ? "zh-CN" : (LANG === "zh-TW" ? "zh-TW" : (LANG === "ja" ? "ja" : "en")));
343 document.title = t("page_title");
344 document.querySelectorAll("[data-i18n]").forEach(function (el) {
345 el.textContent = t(el.getAttribute("data-i18n"));
346 });
347 document.querySelectorAll("[data-i18n-placeholder]").forEach(function (el) {
348 el.placeholder = t(el.getAttribute("data-i18n-placeholder"));
349 });
350 document.querySelectorAll("[data-i18n-title]").forEach(function (el) {
351 el.title = t(el.getAttribute("data-i18n-title"));
352 });
353 updateNavLabel();
354 }
355
356 var LANG_NAMES = { zh: "中文", en: "English", ja: "日本語", "zh-TW": "繁體中文" };
357
358 function refreshLangUI(lang) {
359 // Custom dropdown (OS-independent): button shows the CURRENT language.
360 var cur = document.getElementById("lang-current");
361 if (cur) cur.textContent = LANG_NAMES[lang] || lang;
362 var btn = document.getElementById("btn-lang-toggle");
363 if (btn) btn.title = t("lang_toggle_title");
364 document.querySelectorAll("#lang-menu li").forEach(function (li) {
365 var selected = li.getAttribute("data-lang") === lang;
366 li.classList.toggle("selected", selected);
367 li.setAttribute("aria-selected", selected ? "true" : "false");
368 });
369 }
370
371 function setLang(lang) {
372 if (lang !== "zh" && lang !== "en" && lang !== "ja" && lang !== "zh-TW") return;
373 LANG = lang;
374 try { window.localStorage.setItem("ppt_lang", lang); } catch (e) { /* ignore */ }
375 applyI18n();
376 refreshLangUI(lang);
377 // Re-render dynamic regions so they pick up the new language
378 updateSelectionPanel();
379 updateAnnotationList();
380 updateUndoButton();
381 updatePendingStatus();
382 initAnnotationQuickActions();
383 loadSlides();
384 }
385
386 // ---- DOM refs ---------------------------------------------------
387 var slideListEl = document.getElementById("slide-list");
388 var svgPlaceholder = document.getElementById("svg-placeholder");
389 var svgContent = document.getElementById("svg-content");
390 var selectedElementEl = document.getElementById("selected-element");
391 var annotationInput = document.getElementById("annotation-input");
392 var annotationQuickActions = document.getElementById("annotation-quick-actions");
393 var annotationText = document.getElementById("annotation-text");
394 var btnAddAnnotation = document.getElementById("btn-add-annotation");
395 var annotationsEl = document.getElementById("annotations");
396 var btnUndo = document.getElementById("btn-undo");
397 var btnSave = document.getElementById("btn-save");
398 var btnExitPreview = document.getElementById("btn-exit-preview");
399 var modalOverlay = document.getElementById("modal-overlay");
400 var modalMessage = document.getElementById("modal-message");
401 var modalConfirm = document.getElementById("modal-confirm");
402 var modalCancel = document.getElementById("modal-cancel");
403 var elementPropsEl = document.getElementById("element-props");
404 var pendingStatusEl = document.getElementById("pending-status");
405
406 var navFirstBtn = document.getElementById("nav-first");
407 var navPrevBtn = document.getElementById("nav-prev");
408 var navNextBtn = document.getElementById("nav-next");
409 var navLastBtn = document.getElementById("nav-last");
410 var navCounterEl = document.getElementById("nav-counter");
411 var navNameEl = document.getElementById("nav-name");
412
413 // ---- State ------------------------------------------------------
414 var currentSlide = null; // filename, e.g. "slide_01.svg"
415 var waitingForSlide = null; // current slide is temporarily unreadable in live mode
416 var slideNames = []; // ordered slide filenames for navigation
417 var selectedElementIds = new Set(); // id attrs of selected SVG elements
418 var slideAnnotations = {}; // {element_id: annotation_text} for current slide
419 var liveMode = false;
420 var slidePollTimer = null;
421 var pendingModalAction = "submit";
422 var slideMtimes = {}; // {name: mtime} — last-seen mtime for each slide
423 var reloadBannerEl = null; // singleton banner element shown when currentSlide mtime drifts
424 var editStackCount = {}; // {name: staged edit count} — mirrors backend PENDING_EDITS
425 var savedHintShown = false; // show the "staged edit" hint once per session
426 var matrixHintShown = false; // show transform-matrix export hint once per session
427 var matrixEditSlides = {}; // {name: true} when a staged edit wrote transform=matrix(...)
428 var annotationsDirty = false; // unsaved annotations added/removed this session
429 var annotationEditSlides = {}; // {name: true} when annotations changed this session
430
431 function pendingDirectEditCount() {
432 return Object.keys(editStackCount).reduce(function (total, name) {
433 return total + Math.max(0, editStackCount[name] || 0);
434 }, 0);
435 }
436
437 function pendingDirectEditSlides() {
438 return Object.keys(editStackCount).filter(function (name) {
439 return (editStackCount[name] || 0) > 0;
440 });
441 }
442
443 function pendingAnnotationSlides() {
444 return Object.keys(annotationEditSlides).filter(function (name) {
445 return !!annotationEditSlides[name];
446 });
447 }
448
449 function pendingAnnotationChangeCount() {
450 return pendingAnnotationSlides().length;
451 }
452
453 function pendingSlideNames() {
454 var seen = {};
455 pendingDirectEditSlides().concat(pendingAnnotationSlides()).forEach(function (name) {
456 seen[name] = true;
457 });
458 return Object.keys(seen).sort();
459 }
460
461 function updatePendingStatus() {
462 if (!pendingStatusEl) return;
463 var edits = pendingDirectEditCount();
464 var annotations = pendingAnnotationChangeCount();
465 if (edits === 0 && annotations === 0) {
466 pendingStatusEl.className = "pending-status empty";
467 pendingStatusEl.textContent = t("pending_none");
468 return;
469 }
470 var pages = pendingSlideNames();
471 pendingStatusEl.className = "pending-status active";
472 pendingStatusEl.innerHTML =
473 '<div>' + escapeHtml(t("pending_summary", {
474 edits: edits,
475 annotations: annotations,
476 })) + '</div>' +
477 (pages.length ? '<div class="pending-pages">' + escapeHtml(t("pending_pages", {
478 pages: pages.join(", "),
479 })) + '</div>' : '');
480 }
481
482 function markAnnotationChanged(slide) {
483 if (!slide) return;
484 annotationEditSlides[slide] = true;
485 annotationsDirty = true;
486 updatePendingStatus();
487 }
488
489 function initAnnotationQuickActions() {
490 if (!annotationQuickActions) return;
491 var actions = [
492 { key: "quick_align" },
493 { key: "quick_resize" },
494 { key: "quick_replace_image" },
495 { key: "quick_copy" },
496 { key: "quick_relayout" },
497 ];
498 annotationQuickActions.innerHTML = "";
499 actions.forEach(function (action) {
500 var btn = document.createElement("button");
501 btn.type = "button";
502 btn.className = "annotation-quick-chip";
503 btn.textContent = t(action.key);
504 btn.addEventListener("click", function () {
505 var label = t(action.key);
506 var prefix = (LANG === "zh" || LANG === "ja") ? label + ":" : label + ": ";
507 if (!annotationText.value.trim()) {
508 annotationText.value = prefix;
509 } else if (annotationText.value.indexOf(prefix) === -1) {
510 annotationText.value = prefix + annotationText.value;
511 }
512 annotationText.focus();
513 annotationText.selectionStart = annotationText.selectionEnd = annotationText.value.length;
514 });
515 annotationQuickActions.appendChild(btn);
516 });
517 }
518
519 // Staged edits live in server memory until "Apply changes"; the server can
520 // still idle-timeout or be killed and drop them. Warn before the tab leaves
521 // so the user remembers to apply (browsers show their own generic prompt).
522 function hasUnsavedWork() {
523 if (annotationsDirty) return true;
524 return Object.keys(editStackCount).some(function (k) {
525 return editStackCount[k] > 0;
526 });
527 }
528 window.addEventListener("beforeunload", function (e) {
529 if (!hasUnsavedWork()) return undefined;
530 e.preventDefault();
531 e.returnValue = "";
532 return "";
533 });
534
535 function readSlideFromLocation() {
536 try {
537 var params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
538 return params.get("slide");
539 } catch (err) {
540 return null;
541 }
542 }
543
544 function rememberSlideInLocation(name) {
545 if (!name) return;
546 var params;
547 try {
548 params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
549 } catch (err) {
550 params = new URLSearchParams();
551 }
552 params.set("slide", name);
553 var url = window.location.pathname + window.location.search + "#" + params.toString();
554 window.history.replaceState(null, "", url);
555 }
556
557 function currentSlideIndex() {
558 if (!currentSlide) return -1;
559 return slideNames.indexOf(currentSlide);
560 }
561
562 function gotoSlideIndex(idx) {
563 if (idx < 0 || idx >= slideNames.length) return;
564 var name = slideNames[idx];
565 if (name === currentSlide) return;
566 var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(name) + '"]');
567 selectSlide(name, item || undefined);
568 }
569
570 function cssAttr(value) {
571 return String(value).replace(/"/g, '\\"');
572 }
573
574 function updateNavLabel() {
575 if (!navCounterEl) return;
576 var total = slideNames.length;
577 if (total === 0 || !currentSlide) {
578 navCounterEl.textContent = t("nav_empty");
579 if (navNameEl) navNameEl.textContent = "";
580 } else {
581 var idx = currentSlideIndex();
582 navCounterEl.textContent = t("nav_counter", { current: idx + 1, total: total });
583 if (navNameEl) navNameEl.textContent = currentSlide;
584 }
585 var idx2 = currentSlideIndex();
586 var hasCurrent = idx2 >= 0;
587 if (navFirstBtn) navFirstBtn.disabled = !hasCurrent || idx2 === 0;
588 if (navPrevBtn) navPrevBtn.disabled = !hasCurrent || idx2 <= 0;
589 if (navNextBtn) navNextBtn.disabled = !hasCurrent || idx2 >= total - 1;
590 if (navLastBtn) navLastBtn.disabled = !hasCurrent || idx2 >= total - 1;
591 }
592
593 // ================================================================
594 // 1. loadSlides -- GET /api/slides
595 // ================================================================
596 function loadSlides() {
597 return fetch("/api/slides")
598 .then(function (res) { return res.json(); })
599 .then(function (data) {
600 slideListEl.innerHTML = "";
601 var slides = data.slides || [];
602 slideNames = slides.map(function (s) { return s.name; });
603
604 if (slides.length === 0) {
605 var empty = document.createElement("div");
606 empty.className = "slide-list-empty";
607 empty.textContent = liveMode
608 ? t("empty_waiting_slides")
609 : t("empty_no_slides");
610 slideListEl.appendChild(empty);
611 if (!currentSlide) {
612 svgPlaceholder.style.display = "block";
613 svgPlaceholder.textContent = liveMode
614 ? t("placeholder_live_ready")
615 : t("empty_no_slides");
616 svgContent.style.display = "none";
617 }
618 updateNavLabel();
619 updatePendingStatus();
620 return;
621 }
622
623 var currentExists = false;
624 var currentReady = false;
625 var currentMtimeChanged = false;
626 slides.forEach(function (s) {
627 if (s.name === currentSlide) {
628 currentExists = true;
629 currentReady = s.ok !== false;
630 // Compare against the mtime we recorded when we last rendered this slide.
631 var lastSeen = slideMtimes[s.name];
632 if (lastSeen !== undefined && s.mtime && s.mtime !== lastSeen) {
633 currentMtimeChanged = true;
634 }
635 }
636 // Track every slide's mtime for the next poll (only update non-current here;
637 // currentSlide's mtime is updated by selectSlide so we can detect drift).
638 if (s.name !== currentSlide && s.mtime !== undefined) {
639 slideMtimes[s.name] = s.mtime;
640 }
641
642 var item = document.createElement("div");
643 item.className = "slide-item" + (s.name === currentSlide ? " active" : "");
644 if (s.ok === false) {
645 item.className += " slide-error";
646 item.title = t("slide_error_tooltip") + (s.error || "");
647 }
648 item.setAttribute("data-name", s.name);
649
650 var nameSpan = document.createElement("span");
651 nameSpan.className = "slide-name";
652 nameSpan.textContent = s.name;
653 item.appendChild(nameSpan);
654
655 if (s.annotation_count > 0) {
656 var badge = document.createElement("span");
657 badge.className = "badge";
658 badge.textContent = s.annotation_count;
659 item.appendChild(badge);
660 }
661
662 item.addEventListener("click", function () {
663 selectSlide(s.name, item);
664 });
665 slideListEl.appendChild(item);
666 });
667
668 if (!currentSlide || !currentExists) {
669 var rememberedSlide = readSlideFromLocation();
670 var targetSlide = rememberedSlide && slideNames.indexOf(rememberedSlide) !== -1
671 ? rememberedSlide
672 : slides[0].name;
673 selectSlide(targetSlide);
674 } else if (waitingForSlide === currentSlide) {
675 if (currentReady) {
676 selectSlide(currentSlide);
677 }
678 } else if (currentMtimeChanged) {
679 showReloadBanner(currentSlide);
680 }
681 updateNavLabel();
682 updatePendingStatus();
683 })
684 .catch(function (err) {
685 console.error("loadSlides:", err);
686 showError(t("err_load_slides") + err.message);
687 });
688 }
689
690 // ================================================================
691 // 2. selectSlide -- GET /api/slide/{name}
692 // ================================================================
693 function waitForSlideRewrite(name) {
694 if (!liveMode || name !== currentSlide) return;
695 waitingForSlide = name;
696 svgPlaceholder.style.display = "block";
697 svgPlaceholder.textContent = t("placeholder_slide_writing");
698 svgContent.style.display = "none";
699 }
700
701 function selectSlide(name, el) {
702 if (!el) {
703 el = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(name) + '"]');
704 }
705 // Update active class in sidebar
706 document.querySelectorAll(".slide-item").forEach(function (it) {
707 it.classList.remove("active");
708 });
709 if (el) el.classList.add("active");
710
711 currentSlide = name;
712 waitingForSlide = null;
713 rememberSlideInLocation(name);
714 selectedElementIds.clear();
715 slideAnnotations = {};
716 updateNavLabel();
717
718 // Reset right panel and rubber band
719 cancelRubberBand();
720 clearSelection();
721
722 // Selecting a slide implicitly dismisses any stale "page updated" banner.
723 hideReloadBanner();
724
725 fetch("/api/slide/" + encodeURIComponent(name))
726 .then(function (res) { return res.json(); })
727 .then(function (data) {
728 if (name !== currentSlide) return;
729 if (data.error) {
730 console.error("selectSlide:", data.error);
731 showError(t("err_load_slide") + data.error);
732 waitForSlideRewrite(name);
733 return;
734 }
735 // Render SVG
736 svgPlaceholder.style.display = "none";
737 svgContent.style.display = "block";
738 svgContent.innerHTML = sanitizeSvg(data.content);
739
740 // Empty-canvas guard: surface a clear error if the SVG parsed
741 // to nothing renderable (issue #115's silent-blank scenario).
742 var rootSvg = svgContent.querySelector("svg");
743 // viewBox is the PPT Master canvas authority. Normalize the
744 // preview DOM from it so stale or missing root width/height
745 // cannot shrink the slide. View-layer only — the file on disk
746 // is never touched.
747 if (rootSvg) {
748 var vb = (rootSvg.getAttribute("viewBox") || "").trim().split(/[\s,]+/);
749 var vbWidth = Number(vb[2]);
750 var vbHeight = Number(vb[3]);
751 if (
752 vb.length === 4 &&
753 Number.isFinite(vbWidth) &&
754 Number.isFinite(vbHeight) &&
755 vbWidth > 0 &&
756 vbHeight > 0
757 ) {
758 rootSvg.setAttribute("width", vb[2]);
759 rootSvg.setAttribute("height", vb[3]);
760 }
761 }
762 var hasContent = false;
763 if (rootSvg) {
764 var children = rootSvg.querySelectorAll("*");
765 for (var i = 0; i < children.length; i++) {
766 var ctag = children[i].tagName.toLowerCase();
767 if (ctag !== "defs" && ctag !== "style" && ctag !== "title" && ctag !== "desc") {
768 hasContent = true;
769 break;
770 }
771 }
772 }
773 if (!rootSvg || !hasContent) {
774 showError(t("err_empty_svg"));
775 svgPlaceholder.style.display = "block";
776 svgPlaceholder.textContent = t("err_empty_svg");
777 svgContent.style.display = "none";
778 return;
779 }
780
781 // Non-fatal warnings (e.g. icon inline failures): surface as a
782 // single combined toast so users know why something looks off.
783 if (data.warnings && data.warnings.length > 0) {
784 var names = data.warnings.map(function (w) {
785 return w.icon || "(unknown)";
786 }).join(", ");
787 showWarning(t("warn_icon_inline", {
788 count: data.warnings.length,
789 names: names,
790 }));
791 }
792
793 // Record the mtime so the next poll can detect on-disk drift.
794 if (data.mtime !== undefined) {
795 slideMtimes[name] = data.mtime;
796 }
797
798 // Build annotations map from response
799 (data.annotations || []).forEach(function (a) {
800 slideAnnotations[a.element_id] = a.annotation;
801 });
802
803 editStackCount[name] = data.undo_depth || 0;
804 setupSvgInteraction();
805 refreshAnnotationVisuals();
806 updateAnnotationList();
807 updateUndoButton();
808 updatePendingStatus();
809 })
810 .catch(function (err) {
811 if (name !== currentSlide) return;
812 console.error("selectSlide:", err);
813 showError(t("err_load_slide") + err.message);
814 waitForSlideRewrite(name);
815 });
816 }
817
818 // ================================================================
819 // 3. setupSvgInteraction
820 // ================================================================
821 var SKIP_TAGS = ["defs", "style", "title", "desc"];
822
823 function setupSvgInteraction() {
824 var svg = svgContent.querySelector("svg");
825 if (!svg) return;
826
827 // Visual class only — selectability is handled by the delegated handler below.
828 // Skipping the per-element addEventListener brings listener-registration time
829 // from O(n) to O(1), which matters on decks with hundreds of elements per slide.
830 var allEls = svg.querySelectorAll("*");
831 allEls.forEach(function (el) {
832 var tag = el.tagName.toLowerCase();
833 if (SKIP_TAGS.indexOf(tag) !== -1) return;
834 if (el === svg) return;
835 el.classList.add("svg-selectable");
836 });
837
838 svg.addEventListener("click", function (e) {
839 // Skip the synthetic click that follows a rubber-band drag-release.
840 if (suppressNextSvgClick) {
841 suppressNextSvgClick = false;
842 return;
843 }
844 var target = e.target;
845 // Blank-area click on the svg root → clear selection.
846 if (target === svg) {
847 clearSelection();
848 return;
849 }
850 // Ignore clicks bubbling out of non-interactive subtrees.
851 if (target.closest && target.closest("defs, style, title, desc")) return;
852 // Backend assign_temp_ids() guarantees every element has an id, so
853 // closest("[id]") will always find a hit. The exclusion of `svg`
854 // itself routes "click outside any real shape" to clearSelection.
855 // Icon placeholders are expanded only for browser preview. If a
856 // click lands on a generated child, edit the disk-backed data-icon
857 // group instead of an inner preview-only node.
858 var picked = e.altKey && target.closest
859 ? target.closest("g[id]")
860 : null;
861 picked = picked || (target.closest && target.closest("[data-icon][id]")) ||
862 target.closest("[id]");
863 if (!picked || picked === svg) {
864 clearSelection();
865 return;
866 }
867 selectElement(picked, e.ctrlKey || e.metaKey);
868 });
869 }
870
871 // ================================================================
872 // 4. selectElement
873 // ================================================================
874 function selectElement(elem, addToSelection) {
875 var eid = elem.id;
876 if (!eid) return;
877
878 if (addToSelection) {
879 // Ctrl+click: toggle this element
880 if (selectedElementIds.has(eid)) {
881 selectedElementIds.delete(eid);
882 elem.classList.remove("svg-selected");
883 } else {
884 selectedElementIds.add(eid);
885 elem.classList.add("svg-selected");
886 }
887 } else {
888 // Normal click: clear others, select only this one
889 selectedElementIds.forEach(function (id) {
890 if (id !== eid) {
891 var old = svgContent.querySelector("#" + CSS.escape(id));
892 if (old) old.classList.remove("svg-selected");
893 }
894 });
895 selectedElementIds.clear();
896 selectedElementIds.add(eid);
897 elem.classList.add("svg-selected");
898 }
899
900 updateSelectionPanel();
901 }
902
903 // ================================================================
904 // 5. clearSelection
905 // ================================================================
906 function clearSelection() {
907 selectedElementIds.forEach(function (id) {
908 var el = svgContent.querySelector("#" + CSS.escape(id));
909 if (el) el.classList.remove("svg-selected");
910 });
911 selectedElementIds.clear();
912 updateSelectionPanel();
913 }
914
915 function updateSelectionPanel() {
916 var propsEl = elementPropsEl;
917 var count = selectedElementIds.size;
918
919 if (count === 0) {
920 selectedElementEl.classList.add("empty");
921 selectedElementEl.textContent = t("empty_selected_element");
922 annotationInput.style.display = "none";
923 annotationText.value = "";
924 propsEl.style.display = "none";
925 propsEl.innerHTML = "";
926 return;
927 }
928
929 selectedElementEl.classList.remove("empty");
930 propsEl.style.display = "block";
931
932 if (count === 1) {
933 var eid = selectedElementIds.values().next().value;
934 var el = svgContent.querySelector("#" + CSS.escape(eid));
935 if (el) {
936 var tag = el.tagName.toLowerCase();
937 selectedElementEl.innerHTML =
938 '<span class="el-tag">&lt;' + escapeHtml(tag) + '&gt;</span>' +
939 '<span class="el-id">' + escapeHtml(eid) + '</span>';
940 propsEl.innerHTML = renderEditableProps(el);
941 attachPropEditors(el);
942 }
943 } else {
944 selectedElementEl.innerHTML =
945 '<span class="multi-count">' + escapeHtml(t("multi_selected", { count: count })) + '</span>';
946 propsEl.innerHTML = renderMultiSelectSummary(Array.from(selectedElementIds));
947 attachMultiSelectionEditors(Array.from(selectedElementIds));
948 }
949
950 annotationInput.style.display = "block";
951 annotationText.placeholder = count > 1
952 ? t("placeholder_annotation_multi", { count: count })
953 : t("placeholder_annotation");
954 annotationText.value = count === 1
955 ? (slideAnnotations[selectedElementIds.values().next().value] || "")
956 : "";
957 }
958
959 // ---- Rubber band selection ----
960 var rubberBandEl = null;
961 var rubberBandStart = null;
962 var rubberBandUsed = false;
963 var suppressNextSvgClick = false;
964 var RUBBER_BAND_THRESHOLD = 5;
965
966 // ---- Drag-to-move (direct geometry edit on the canvas) ----
967 // Pressing on an already-selected element drags the whole selection. Frames
968 // only update the preview; one staged edit per element is written on release,
969 // reusing attrsForMove + stageEditRequest (and backend coalescing).
970 var dragStart = null; // {x, y} screen coords at mousedown on a selection
971 var dragMoved = false; // crossed the move threshold this gesture
972 var dragEls = []; // top-level selected elements being moved
973 var dragStartAttrs = {}; // {id: startGeometryAttrs} for drift-free preview
974
975 function hitSelectedAncestor(target) {
976 // Walk up from the pressed node to the first element already in the
977 // selection, so a press on a selected shape (or its inner preview
978 // children) starts a drag regardless of how it was selected.
979 var svg = svgContent.querySelector("svg");
980 var node = target;
981 while (node && node !== svg && node.nodeType === 1) {
982 if (node.id && selectedElementIds.has(node.id)) return node;
983 node = node.parentNode;
984 }
985 return null;
986 }
987
988 function screenDeltaToLocal(el, dxScreen, dyScreen) {
989 // Map a screen-pixel delta into the coordinate space computeRenderBox /
990 // attrsForMove use for THIS element (its getCTM() output space). Using the
991 // element's own getCTM ∘ getScreenCTM⁻¹ is robust to how a browser splits
992 // the viewBox scale between getCTM and getScreenCTM, and naturally accounts
993 // for each element's own group transform (correct under multi-select).
994 var sctm = el.getScreenCTM ? el.getScreenCTM() : null;
995 var ctm = el.getCTM ? el.getCTM() : null;
996 if (!sctm || !ctm) return { x: dxScreen, y: dyScreen };
997 try {
998 var n = ctm.multiply(sctm.inverse()); // linear part: screen → local delta
999 var lx = n.a * dxScreen + n.c * dyScreen;
1000 var ly = n.b * dxScreen + n.d * dyScreen;
1001 // Singular CTM → SVGMatrix.inverse() throws, DOMMatrix yields NaN.
1002 // Either way, fall back to the raw delta rather than wedge the drag.
1003 if (!isFinite(lx) || !isFinite(ly)) return { x: dxScreen, y: dyScreen };
1004 return { x: lx, y: ly };
1005 } catch (err) {
1006 return { x: dxScreen, y: dyScreen };
1007 }
1008 }
1009
1010 function beginDrag() {
1011 dragEls = topLevelSelectedElements(Array.from(selectedElementIds));
1012 dragStartAttrs = {};
1013 dragEls.forEach(function (el) {
1014 // attrsForMove(el, 0, 0) returns the element's current geometry in the
1015 // exact attribute shape a move writes (x/y, cx/cy, … or transform);
1016 // restoring it before each frame keeps the preview drift-free.
1017 try { dragStartAttrs[el.id] = attrsForMove(el, 0, 0); }
1018 catch (err) { dragStartAttrs[el.id] = null; }
1019 });
1020 document.body.classList.add("svg-dragging");
1021 }
1022
1023 function applyAttrSnapshot(el, sa) {
1024 if (!sa) return;
1025 applyElementAttrs(el, sa);
1026 }
1027
1028 var INLINE_GEOMETRY_STYLE_PROPERTIES = {
1029 rect: new Set(["x", "y", "width", "height", "rx", "ry"]),
1030 circle: new Set(["cx", "cy", "r"]),
1031 ellipse: new Set(["cx", "cy", "rx", "ry"]),
1032 image: new Set(["x", "y", "width", "height"]),
1033 svg: new Set(["x", "y", "width", "height"]),
1034 use: new Set(["x", "y", "width", "height"])
1035 };
1036
1037 function stripEditedInlineGeometry(el, attrs) {
1038 var style = el.getAttribute("style");
1039 var supported = INLINE_GEOMETRY_STYLE_PROPERTIES[localName(el)];
1040 if (!style || !supported) return;
1041 var edited = new Set(Object.keys(attrs || {}).map(function (key) {
1042 return String(key).toLowerCase();
1043 }).filter(function (key) { return supported.has(key); }));
1044 if (edited.size === 0) return;
1045
1046 var retained = [];
1047 var changed = false;
1048 style.split(";").forEach(function (rawDeclaration) {
1049 var declaration = rawDeclaration.trim();
1050 if (!declaration) return;
1051 var colon = declaration.indexOf(":");
1052 if (colon < 0) {
1053 retained.push(declaration);
1054 return;
1055 }
1056 var name = declaration.slice(0, colon).trim().toLowerCase();
1057 if (edited.has(name)) {
1058 changed = true;
1059 return;
1060 }
1061 retained.push(declaration);
1062 });
1063 if (!changed) return;
1064 if (retained.length > 0) el.setAttribute("style", retained.join("; "));
1065 else el.removeAttribute("style");
1066 }
1067
1068 function applyElementAttrs(el, attrs) {
1069 stripEditedInlineGeometry(el, attrs);
1070 if (localName(el) === "g" && el.hasAttribute("data-icon") &&
1071 attrs.x !== undefined && attrs.y !== undefined) {
1072 // The preview expands <use data-icon> into a <g>, but disk still owns
1073 // the source <use>. Keep the browser geometry in sync while the staged
1074 // edit writes x/y back to the real placeholder instead of persisting a
1075 // transform.
1076 var m = ownMatrix(el);
1077 el.setAttribute("transform", "matrix(" + [
1078 formatCoord(m.a), formatCoord(m.b), formatCoord(m.c),
1079 formatCoord(m.d), formatCoord(parseFloat(attrs.x)),
1080 formatCoord(parseFloat(attrs.y))
1081 ].join(",") + ")");
1082 el.setAttribute("data-use-x", attrs.x);
1083 el.setAttribute("data-use-y", attrs.y);
1084 }
1085 Object.keys(attrs).forEach(function (k) {
1086 if (localName(el) === "g" && el.hasAttribute("data-icon") &&
1087 (k === "x" || k === "y" || k === "transform")) return;
1088 if (attrs[k] === null || attrs[k] === undefined) el.removeAttribute(k);
1089 else el.setAttribute(k, attrs[k]);
1090 });
1091 }
1092
1093 function localName(el) {
1094 return el && el.tagName ? el.tagName.toLowerCase() : "";
1095 }
1096
1097 function markCommittedAttrs(el, attrs) {
1098 if (attrs && attrs.transform === null &&
1099 localName(el) === "g" && el.hasAttribute("data-icon")) {
1100 el.removeAttribute("data-use-has-transform");
1101 }
1102 }
1103
1104 function payloadForCurrentElementState(el, payload) {
1105 if (!payload || !payload.attrs || payload.attrs.transform !== null ||
1106 localName(el) !== "g" || !el.hasAttribute("data-icon") ||
1107 el.hasAttribute("data-use-has-transform")) {
1108 return payload;
1109 }
1110 var attrs = Object.assign({}, payload.attrs);
1111 delete attrs.transform;
1112 return Object.assign({}, payload, { attrs: attrs });
1113 }
1114
1115 function restoreDragStart(el) {
1116 applyAttrSnapshot(el, dragStartAttrs[el.id]);
1117 }
1118
1119 function applyMoveLocal(el, dxScreen, dyScreen) {
1120 // Each element converts the shared screen delta into its own local space,
1121 // so a selection spanning differently-transformed groups still tracks the
1122 // pointer. Restore-then-apply keeps every frame relative to the start.
1123 restoreDragStart(el);
1124 var d = screenDeltaToLocal(el, dxScreen, dyScreen);
1125 var attrs;
1126 try { attrs = attrsForMove(el, d.x, d.y); }
1127 catch (err) { attrs = null; }
1128 if (!attrs) return null;
1129 applyElementAttrs(el, attrs);
1130 return attrs;
1131 }
1132
1133 function updateDragPreview(dxScreen, dyScreen) {
1134 dragEls.forEach(function (el) { applyMoveLocal(el, dxScreen, dyScreen); });
1135 if (dragEls.length === 1) refreshGeoInputs(dragEls[0], elementPropsEl);
1136 }
1137
1138 function commitDrag(dxScreen, dyScreen) {
1139 var single = dragEls.length === 1;
1140 dragEls.forEach(function (el) {
1141 // Capture the pre-drag snapshot in the closure: endDrag() clears
1142 // dragStartAttrs synchronously after this call, before the staging
1143 // promise can reject, so the rollback can't rely on the global.
1144 var startAttrs = dragStartAttrs[el.id];
1145 var attrs = applyMoveLocal(el, dxScreen, dyScreen);
1146 if (!attrs) return;
1147 var payload = payloadForMovedElement(el, attrs);
1148 stageEditRequest(el.id, payload).then(function () {
1149 if (payload.attrs) markCommittedAttrs(el, payload.attrs);
1150 if (payload.promote_tspan) {
1151 var promoted = promoteTspanDom(el, payload.promote_tspan);
1152 if (promoted && single) refreshGeoInputs(promoted, elementPropsEl);
1153 }
1154 }).catch(function (err) {
1155 // Optimistic preview already moved the element; roll it back so the
1156 // canvas never shows a move the server never staged.
1157 applyAttrSnapshot(el, startAttrs);
1158 if (single) refreshGeoInputs(el, elementPropsEl);
1159 showError(t("err_edit") + err.message);
1160 });
1161 });
1162 }
1163
1164 function endDrag() {
1165 dragStart = null;
1166 dragMoved = false;
1167 dragEls = [];
1168 dragStartAttrs = {};
1169 document.body.classList.remove("svg-dragging");
1170 }
1171
1172 // Per-element nudge staging is serialized: a held arrow key fires many
1173 // keydowns, each sending an *absolute* position. Without ordering, the
1174 // unlocked backend could coalesce them out of order and leave the staged
1175 // value behind the on-screen one. So at most one nudge stage per element is
1176 // in flight; extra keypresses only update the preview and the queued target,
1177 // and the next send reads the current DOM position (= the latest).
1178 var nudgeFlight = {}; // key -> bool: a nudge stage is in flight
1179 var nudgeQueued = {}; // key -> {el, single, slide, attrs}: latest target awaiting send
1180 var nudgeLastOk = {}; // key -> attrs: last position the server confirmed
1181
1182 function nudgeKey(slide, el) { return slide + "|" + el.id; }
1183
1184 function sendNudge(key, el, single, slide, payload) {
1185 if (!payload) return;
1186 payload = payloadForCurrentElementState(el, payload);
1187 nudgeFlight[key] = true;
1188 stageEditRequest(el.id, payload, slide).then(function () {
1189 if (payload.attrs) nudgeLastOk[key] = payload.attrs;
1190 if (payload.attrs) markCommittedAttrs(el, payload.attrs);
1191 nudgeFlight[key] = false;
1192 var q = nudgeQueued[key];
1193 if (q) { delete nudgeQueued[key]; sendNudge(key, q.el, q.single, q.slide, q.payload); }
1194 }).catch(function (err) {
1195 nudgeFlight[key] = false;
1196 delete nudgeQueued[key];
1197 // Re-sync the preview to the last confirmed position so it can't show
1198 // a move the server never staged (only meaningful on the live slide).
1199 if (payload.promote_tspan && el.__promotionRollback) {
1200 var restored = el.__promotionRollback();
1201 if (single && restored) refreshGeoInputs(restored, elementPropsEl);
1202 } else if (nudgeLastOk[key] && slide === currentSlide && el.isConnected) {
1203 applyAttrSnapshot(el, nudgeLastOk[key]);
1204 if (single) refreshGeoInputs(el, elementPropsEl);
1205 }
1206 showError(t("err_edit") + err.message);
1207 });
1208 }
1209
1210 function stageNudge(el, single, slide, payload) {
1211 var key = nudgeKey(slide, el);
1212 if (nudgeFlight[key]) {
1213 nudgeQueued[key] = { el: el, single: single, slide: slide, payload: payload };
1214 return;
1215 }
1216 sendNudge(key, el, single, slide, payload);
1217 }
1218
1219 function nudgeSelection(dxScreen, dyScreen) {
1220 // Keyboard arrow micro-move: same geometry path as drag. The DOM updates
1221 // immediately; staging is serialized per element (see sendNudge), and
1222 // consecutive same-direction nudges coalesce into one undo step.
1223 var els = topLevelSelectedElements(Array.from(selectedElementIds));
1224 if (els.length === 0) return;
1225 var slide = currentSlide;
1226 var single = els.length === 1;
1227 var lastSingleEl = null;
1228 els.forEach(function (el) {
1229 var key = nudgeKey(slide, el);
1230 if (!nudgeFlight[key] && !nudgeQueued[key]) {
1231 // Fresh burst: re-capture the pre-move baseline from the current
1232 // DOM, so a failed nudge rolls back to *this* burst's start rather
1233 // than a stale position left by an earlier drag/edit/undo.
1234 try { nudgeLastOk[key] = attrsForMove(el, 0, 0); }
1235 catch (err) { delete nudgeLastOk[key]; }
1236 }
1237 var d = screenDeltaToLocal(el, dxScreen, dyScreen);
1238 var attrs;
1239 try { attrs = attrsForMove(el, d.x, d.y); } catch (err) { attrs = null; }
1240 if (!attrs) return;
1241 applyElementAttrs(el, attrs);
1242 var payload = payloadForMovedElement(el, attrs);
1243 if (payload.promote_tspan) {
1244 el = promoteTspanDom(el, payload.promote_tspan) || el;
1245 }
1246 lastSingleEl = el;
1247 stageNudge(el, single, slide, payload);
1248 });
1249 if (single && lastSingleEl) refreshGeoInputs(lastSingleEl, elementPropsEl);
1250 }
1251
1252 // ---- Overlap picker (right-click → list every selectable element here) ----
1253 // Left-click stays a fast "select the topmost" gesture; right-click is the
1254 // opt-in disambiguator for stacked elements, so it never pops on normal use.
1255 var overlapPickerEl = null;
1256
1257 function collectSelectableAt(clientX, clientY) {
1258 var svg = svgContent.querySelector("svg");
1259 if (!svg || !document.elementsFromPoint) return [];
1260 var out = [];
1261 var seen = {};
1262 document.elementsFromPoint(clientX, clientY).forEach(function (node) {
1263 if (!svg.contains(node) || node === svg) return;
1264 if (node.closest && node.closest("defs, style, title, desc")) return;
1265 // Resolve to the disk-backed selectable entity, mirroring the click
1266 // handler: prefer an enclosing data-icon group, else nearest id.
1267 var picked = (node.closest && node.closest("[data-icon][id]")) ||
1268 (node.closest && node.closest("[id]"));
1269 if (!picked || picked === svg || !picked.id || seen[picked.id]) return;
1270 seen[picked.id] = true;
1271 out.push(picked);
1272 });
1273 return out; // top → bottom in paint order
1274 }
1275
1276 function closeOverlapPicker() {
1277 if (!overlapPickerEl) return;
1278 var svg = svgContent.querySelector("svg");
1279 if (svg) {
1280 svg.querySelectorAll(".overlap-hover").forEach(function (e) {
1281 e.classList.remove("overlap-hover");
1282 });
1283 }
1284 overlapPickerEl.remove();
1285 overlapPickerEl = null;
1286 }
1287
1288 function showOverlapPicker(clientX, clientY, candidates) {
1289 closeOverlapPicker();
1290 var menu = document.createElement("div");
1291 menu.id = "overlap-picker";
1292 var cap = document.createElement("div");
1293 cap.className = "overlap-caption";
1294 cap.textContent = t("overlap_caption");
1295 menu.appendChild(cap);
1296 candidates.forEach(function (el) {
1297 var item = document.createElement("div");
1298 item.className = "overlap-item";
1299 var tag = el.tagName.toLowerCase();
1300 var txt = (tag === "text" || tag === "tspan")
1301 ? (el.textContent || "").trim().slice(0, 28) : "";
1302 item.innerHTML = '<span class="overlap-tag">&lt;' + escapeHtml(tag) +
1303 '&gt;</span><span class="overlap-id">' + escapeHtml(el.id) + '</span>' +
1304 (txt ? '<span class="overlap-txt">' + escapeHtml(txt) + '</span>' : '');
1305 item.addEventListener("mouseenter", function () { el.classList.add("overlap-hover"); });
1306 item.addEventListener("mouseleave", function () { el.classList.remove("overlap-hover"); });
1307 item.addEventListener("click", function (ev) {
1308 ev.stopPropagation();
1309 selectElement(el, ev.ctrlKey || ev.metaKey);
1310 closeOverlapPicker();
1311 });
1312 menu.appendChild(item);
1313 });
1314 document.body.appendChild(menu);
1315 // Keep the menu inside the viewport.
1316 var x = Math.min(clientX, window.innerWidth - menu.offsetWidth - 8);
1317 var y = Math.min(clientY, window.innerHeight - menu.offsetHeight - 8);
1318 menu.style.left = Math.max(8, x) + "px";
1319 menu.style.top = Math.max(8, y) + "px";
1320 overlapPickerEl = menu;
1321 }
1322
1323 function initRubberBand() {
1324 var overlay = document.getElementById("rubber-band-overlay");
1325 var container = document.getElementById("svg-container");
1326
1327 container.addEventListener("mousedown", function (e) {
1328 // Only left mouse button
1329 if (e.button !== 0) return;
1330
1331 // Pressing on an already-selected element arms a drag of the whole
1332 // selection instead of a rubber band. Selecting stays a separate
1333 // click, so the background is never dragged by accident.
1334 if (hitSelectedAncestor(e.target)) {
1335 dragStart = { x: e.clientX, y: e.clientY };
1336 dragMoved = false;
1337 return;
1338 }
1339
1340 // Always start tracking — rubber band only activates when
1341 // mousemove exceeds the threshold. This allows clicking on any
1342 // element (including SVG background rects) to still trigger
1343 // the element's click handler for selection.
1344 rubberBandStart = { x: e.clientX, y: e.clientY };
1345 rubberBandUsed = false;
1346 });
1347
1348 document.addEventListener("mousemove", function (e) {
1349 if (dragStart) {
1350 var ddx = e.clientX - dragStart.x;
1351 var ddy = e.clientY - dragStart.y;
1352 if (!dragMoved && Math.sqrt(ddx * ddx + ddy * ddy) < RUBBER_BAND_THRESHOLD) {
1353 return; // below threshold — still a click, let selection stand
1354 }
1355 if (!dragMoved) {
1356 dragMoved = true;
1357 beginDrag();
1358 }
1359 updateDragPreview(ddx, ddy);
1360 return;
1361 }
1362 if (!rubberBandStart) return;
1363
1364 var dx = e.clientX - rubberBandStart.x;
1365 var dy = e.clientY - rubberBandStart.y;
1366 var dist = Math.sqrt(dx * dx + dy * dy);
1367
1368 if (dist < RUBBER_BAND_THRESHOLD) {
1369 return;
1370 }
1371
1372 // Threshold exceeded — this is a drag, not a click
1373 if (!rubberBandUsed) {
1374 rubberBandUsed = true;
1375 overlay.classList.add("active");
1376 }
1377
1378 if (!rubberBandEl) {
1379 rubberBandEl = document.createElement("div");
1380 rubberBandEl.id = "rubber-band";
1381 document.body.appendChild(rubberBandEl);
1382 }
1383
1384 var x = Math.min(rubberBandStart.x, e.clientX);
1385 var y = Math.min(rubberBandStart.y, e.clientY);
1386 var w = Math.abs(dx);
1387 var h = Math.abs(dy);
1388
1389 rubberBandEl.style.left = x + "px";
1390 rubberBandEl.style.top = y + "px";
1391 rubberBandEl.style.width = w + "px";
1392 rubberBandEl.style.height = h + "px";
1393 });
1394
1395 document.addEventListener("mouseup", function (e) {
1396 if (dragStart) {
1397 if (dragMoved) {
1398 commitDrag(e.clientX - dragStart.x, e.clientY - dragStart.y);
1399 // Swallow the click that follows the drag-release so it does
1400 // not re-trigger selection on the dropped element.
1401 suppressNextSvgClick = true;
1402 window.setTimeout(function () { suppressNextSvgClick = false; }, 50);
1403 }
1404 endDrag();
1405 return;
1406 }
1407 if (!rubberBandStart) return;
1408
1409 overlay.classList.remove("active");
1410
1411 var dx = e.clientX - rubberBandStart.x;
1412 var dy = e.clientY - rubberBandStart.y;
1413 var dist = Math.sqrt(dx * dx + dy * dy);
1414
1415 if (rubberBandEl) {
1416 rubberBandEl.remove();
1417 rubberBandEl = null;
1418 }
1419
1420 // Only process if drag was beyond threshold
1421 if (dist >= RUBBER_BAND_THRESHOLD) {
1422 var rect = {
1423 left: Math.min(rubberBandStart.x, e.clientX),
1424 top: Math.min(rubberBandStart.y, e.clientY),
1425 right: Math.max(rubberBandStart.x, e.clientX),
1426 bottom: Math.max(rubberBandStart.y, e.clientY)
1427 };
1428
1429 if (!e.ctrlKey && !e.metaKey) {
1430 clearSelection();
1431 }
1432
1433 selectByRubberBand(rect);
1434 suppressNextSvgClick = true;
1435 window.setTimeout(function () {
1436 suppressNextSvgClick = false;
1437 }, 50);
1438 } else {
1439 // Below threshold: treat as click on empty space
1440 if (!e.ctrlKey && !e.metaKey) {
1441 clearSelection();
1442 }
1443 }
1444
1445 rubberBandStart = null;
1446 });
1447
1448 // Right-click → list every selectable element under the pointer, so
1449 // stacked/overlapping shapes can be reached without blind cycling.
1450 container.addEventListener("contextmenu", function (e) {
1451 var svg = svgContent.querySelector("svg");
1452 if (!svg) return;
1453 var cands = collectSelectableAt(e.clientX, e.clientY);
1454 if (cands.length === 0) return; // nothing here → native menu
1455 e.preventDefault();
1456 if (cands.length === 1) { selectElement(cands[0], false); return; }
1457 showOverlapPicker(e.clientX, e.clientY, cands);
1458 });
1459
1460 // Dismiss the picker on any press outside it (item clicks stop their own
1461 // propagation, so this never fires before a selection is made).
1462 document.addEventListener("mousedown", function (e) {
1463 if (overlapPickerEl && !overlapPickerEl.contains(e.target)) {
1464 closeOverlapPicker();
1465 }
1466 });
1467 }
1468
1469 function cancelRubberBand() {
1470 rubberBandStart = null;
1471 if (rubberBandEl) {
1472 rubberBandEl.remove();
1473 rubberBandEl = null;
1474 }
1475 var ov = document.getElementById("rubber-band-overlay");
1476 if (ov) ov.classList.remove("active");
1477 suppressNextSvgClick = false;
1478 endDrag();
1479 closeOverlapPicker();
1480 }
1481
1482 function selectByRubberBand(screenRect) {
1483 var svg = svgContent.querySelector("svg");
1484 if (!svg) return;
1485
1486 var selectableEls = svg.querySelectorAll(".svg-selectable");
1487 selectableEls.forEach(function (el) {
1488 try {
1489 var bbox = el.getBBox();
1490 var ctm = el.getScreenCTM();
1491 if (!ctm) return;
1492
1493 // Transform bbox corners to screen coordinates
1494 var corners = [
1495 { x: bbox.x, y: bbox.y },
1496 { x: bbox.x + bbox.width, y: bbox.y },
1497 { x: bbox.x, y: bbox.y + bbox.height },
1498 { x: bbox.x + bbox.width, y: bbox.y + bbox.height }
1499 ];
1500
1501 var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1502 corners.forEach(function (c) {
1503 var sx = c.x * ctm.a + c.y * ctm.c + ctm.e;
1504 var sy = c.x * ctm.b + c.y * ctm.d + ctm.f;
1505 if (sx < minX) minX = sx;
1506 if (sy < minY) minY = sy;
1507 if (sx > maxX) maxX = sx;
1508 if (sy > maxY) maxY = sy;
1509 });
1510
1511 // AABB intersection
1512 if (minX < screenRect.right && maxX > screenRect.left &&
1513 minY < screenRect.bottom && maxY > screenRect.top) {
1514 var eid = el.id;
1515 if (eid) {
1516 selectedElementIds.add(eid);
1517 el.classList.add("svg-selected");
1518 }
1519 }
1520 } catch (err) {
1521 // getBBox can throw for elements with no geometry
1522 }
1523 });
1524
1525 updateSelectionPanel();
1526 }
1527
1528 // ================================================================
1529 // Keyboard shortcuts
1530 // ================================================================
1531 function initKeyboardShortcuts() {
1532 document.addEventListener("keydown", function (e) {
1533 // Ctrl+Z / Cmd+Z: drop the last staged edit. Let inputs/selects handle
1534 // their own native undo when focused.
1535 if ((e.ctrlKey || e.metaKey) && (e.key === "z" || e.key === "Z")) {
1536 var ae = document.activeElement;
1537 if (ae && (ae === annotationText || ae.tagName === "INPUT" ||
1538 ae.tagName === "TEXTAREA" || ae.tagName === "SELECT")) {
1539 return;
1540 }
1541 e.preventDefault();
1542 runUndo();
1543 return;
1544 }
1545
1546 // Ctrl+A / Cmd+A: select all elements
1547 if ((e.ctrlKey || e.metaKey) && e.key === "a") {
1548 // Don't intercept if focus is in textarea
1549 if (document.activeElement === annotationText) return;
1550
1551 e.preventDefault();
1552 var svg = svgContent.querySelector("svg");
1553 if (!svg) return;
1554
1555 svg.querySelectorAll(".svg-selectable").forEach(function (el) {
1556 var eid = el.id;
1557 if (eid) {
1558 selectedElementIds.add(eid);
1559 el.classList.add("svg-selected");
1560 }
1561 });
1562 updateSelectionPanel();
1563 }
1564
1565 // Escape: close the overlap picker first if open, else clear selection.
1566 if (e.key === "Escape") {
1567 if (document.activeElement === annotationText) return;
1568 if (overlapPickerEl) { closeOverlapPicker(); return; }
1569 clearSelection();
1570 }
1571
1572 // Slide navigation: ArrowLeft/Right + Home/End (skip while typing)
1573 if (document.activeElement === annotationText) return;
1574 if (e.ctrlKey || e.metaKey || e.altKey) return;
1575
1576 // Arrow keys nudge the selection (1px, Shift = 10px) instead of
1577 // navigating slides whenever something is selected.
1578 if (selectedElementIds.size > 0 &&
1579 (e.key === "ArrowLeft" || e.key === "ArrowRight" ||
1580 e.key === "ArrowUp" || e.key === "ArrowDown")) {
1581 e.preventDefault();
1582 var step = e.shiftKey ? 10 : 1;
1583 var nx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
1584 var ny = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0;
1585 nudgeSelection(nx, ny);
1586 return;
1587 }
1588
1589 if (slideNames.length === 0) return;
1590
1591 if (e.key === "ArrowLeft") {
1592 e.preventDefault();
1593 gotoSlideIndex(currentSlideIndex() - 1);
1594 } else if (e.key === "ArrowRight") {
1595 e.preventDefault();
1596 gotoSlideIndex(currentSlideIndex() + 1);
1597 } else if (e.key === "Home") {
1598 e.preventDefault();
1599 gotoSlideIndex(0);
1600 } else if (e.key === "End") {
1601 e.preventDefault();
1602 gotoSlideIndex(slideNames.length - 1);
1603 }
1604 });
1605 }
1606
1607 function initSlideNav() {
1608 if (navFirstBtn) navFirstBtn.addEventListener("click", function () { gotoSlideIndex(0); });
1609 if (navPrevBtn) navPrevBtn.addEventListener("click", function () { gotoSlideIndex(currentSlideIndex() - 1); });
1610 if (navNextBtn) navNextBtn.addEventListener("click", function () { gotoSlideIndex(currentSlideIndex() + 1); });
1611 if (navLastBtn) navLastBtn.addEventListener("click", function () { gotoSlideIndex(slideNames.length - 1); });
1612 }
1613
1614 // ================================================================
1615 // 6. Add annotation -- POST /api/slide/{name}/annotate
1616 // ================================================================
1617 btnAddAnnotation.addEventListener("click", function () {
1618 if (!currentSlide || selectedElementIds.size === 0) return;
1619
1620 var text = annotationText.value.trim();
1621 if (!text) return;
1622
1623 var ids = Array.from(selectedElementIds);
1624 var promises = ids.map(function (eid) {
1625 return fetch("/api/slide/" + encodeURIComponent(currentSlide) + "/annotate", {
1626 method: "POST",
1627 headers: { "Content-Type": "application/json" },
1628 body: JSON.stringify({ element_id: eid, annotation: text })
1629 }).then(jsonOrThrow);
1630 });
1631
1632 Promise.all(promises)
1633 .then(function () {
1634 ids.forEach(function (eid) {
1635 slideAnnotations[eid] = text;
1636 });
1637 markAnnotationChanged(currentSlide);
1638 refreshAnnotationVisuals();
1639 updateAnnotationList();
1640 annotationText.value = "";
1641 loadSlides();
1642 })
1643 .catch(function (err) {
1644 console.error("addAnnotation:", err);
1645 showError(t("err_add_annotation") + err.message);
1646 });
1647 });
1648
1649 // ================================================================
1650 // 7. removeAnnotation -- DELETE /api/slide/{name}/annotate/{id}
1651 // ================================================================
1652 function removeAnnotation(elementId) {
1653 if (!currentSlide) return;
1654
1655 fetch("/api/slide/" + encodeURIComponent(currentSlide) + "/annotate/" + encodeURIComponent(elementId), {
1656 method: "DELETE"
1657 })
1658 .then(function (res) { return res.json(); })
1659 .then(function () {
1660 delete slideAnnotations[elementId];
1661 markAnnotationChanged(currentSlide);
1662 refreshAnnotationVisuals();
1663 updateAnnotationList();
1664 loadSlides();
1665 })
1666 .catch(function (err) {
1667 console.error("removeAnnotation:", err);
1668 showError(t("err_remove_annotation") + err.message);
1669 });
1670 }
1671
1672 // ================================================================
1673 // 8. refreshAnnotationVisuals
1674 // ================================================================
1675 function refreshAnnotationVisuals() {
1676 // Clear all annotated marks
1677 svgContent.querySelectorAll(".svg-annotated").forEach(function (el) {
1678 el.classList.remove("svg-annotated");
1679 });
1680 // Apply marks
1681 Object.keys(slideAnnotations).forEach(function (eid) {
1682 var el = svgContent.querySelector("#" + CSS.escape(eid));
1683 if (el) el.classList.add("svg-annotated");
1684 });
1685 }
1686
1687 // ================================================================
1688 // 9. updateAnnotationList
1689 // ================================================================
1690 function updateAnnotationList() {
1691 annotationsEl.innerHTML = "";
1692
1693 var ids = Object.keys(slideAnnotations);
1694 if (ids.length === 0) {
1695 annotationsEl.innerHTML = '<div class="annotations-empty">' + escapeHtml(t("empty_annotations")) + '</div>';
1696 return;
1697 }
1698
1699 ids.forEach(function (eid) {
1700 var item = document.createElement("div");
1701 item.className = "annotation-item";
1702
1703 // Try to resolve tag from live SVG
1704 var tag = "";
1705 var el = svgContent.querySelector("#" + CSS.escape(eid));
1706 if (el) tag = el.tagName.toLowerCase();
1707
1708 var header = document.createElement("div");
1709 header.className = "ann-header";
1710
1711 var leftSpan = document.createElement("span");
1712 if (tag) {
1713 var tagSpan = document.createElement("span");
1714 tagSpan.className = "ann-tag";
1715 tagSpan.textContent = "<" + tag + ">";
1716 leftSpan.appendChild(tagSpan);
1717 }
1718 var idSpan = document.createElement("span");
1719 idSpan.className = "ann-id";
1720 idSpan.textContent = eid;
1721 leftSpan.appendChild(idSpan);
1722
1723 header.appendChild(leftSpan);
1724
1725 var removeBtn = document.createElement("button");
1726 removeBtn.className = "ann-remove";
1727 removeBtn.innerHTML = "&times;";
1728 removeBtn.title = t("tooltip_remove_annotation");
1729 removeBtn.addEventListener("click", function () {
1730 removeAnnotation(eid);
1731 });
1732 header.appendChild(removeBtn);
1733
1734 item.appendChild(header);
1735
1736 var textDiv = document.createElement("div");
1737 textDiv.className = "ann-text";
1738 textDiv.textContent = slideAnnotations[eid];
1739 item.appendChild(textDiv);
1740
1741 annotationsEl.appendChild(item);
1742 });
1743 }
1744
1745 // ================================================================
1746 // 10. Save all -- two-step: confirm then save
1747 // ================================================================
1748 btnSave.addEventListener("click", function () {
1749 pendingModalAction = "submit";
1750 modalMessage.textContent = t("modal_confirm_submit") +
1751 (hasPendingMatrixEdits() ? t("modal_matrix_transform_note") : "");
1752 modalConfirm.textContent = t("modal_submit");
1753 modalConfirm.style.display = "";
1754 modalCancel.style.display = "";
1755 modalOverlay.style.display = "flex";
1756 });
1757
1758 btnExitPreview.addEventListener("click", function () {
1759 pendingModalAction = "exit";
1760 modalMessage.textContent = t("modal_confirm_exit");
1761 modalConfirm.textContent = t("btn_exit_preview");
1762 modalConfirm.style.display = "";
1763 modalCancel.style.display = "";
1764 modalOverlay.style.display = "flex";
1765 });
1766
1767 modalConfirm.addEventListener("click", function () {
1768 if (pendingModalAction === "exit") {
1769 modalConfirm.style.display = "none";
1770 modalCancel.style.display = "none";
1771 modalMessage.textContent = t("modal_stopping");
1772 fetch("/api/shutdown", {
1773 method: "POST",
1774 headers: { "Content-Type": "application/json" },
1775 body: JSON.stringify({ reason: "exit-preview" })
1776 })
1777 .then(function () {
1778 modalMessage.textContent = t("modal_success_exit");
1779 })
1780 .catch(function () {
1781 modalMessage.textContent = t("modal_success_exit");
1782 });
1783 return;
1784 }
1785
1786 // Step 2: save annotations. Service lifetime is controlled only by Exit preview.
1787 var hadDirectEdits = pendingDirectEditCount() > 0;
1788 var hadAnnotationChanges = pendingAnnotationChangeCount() > 0;
1789 modalConfirm.style.display = "none";
1790 modalCancel.style.display = "none";
1791
1792 // Wait for any just-fired direct edits (drag/nudge) to land first, or
1793 // save-all could race ahead and leave their edits as a fresh pending set.
1794 drainDirectEdits()
1795 .then(function () { return fetch("/api/save-all", { method: "POST" }); })
1796 .then(function (res) { return res.json(); })
1797 .then(function (data) {
1798 if (data.error) {
1799 modalMessage.textContent = t("err_save") + data.error;
1800 } else {
1801 if (hadDirectEdits && hadAnnotationChanges) {
1802 modalMessage.textContent = t("modal_success_mixed");
1803 } else if (hadDirectEdits) {
1804 modalMessage.textContent = t("modal_success_direct_only");
1805 } else if (hadAnnotationChanges) {
1806 modalMessage.textContent = t("modal_success_annotations_only");
1807 } else {
1808 modalMessage.textContent = t("modal_success_submit");
1809 }
1810 editStackCount = {};
1811 matrixEditSlides = {};
1812 matrixHintShown = false;
1813 savedHintShown = false;
1814 annotationsDirty = false;
1815 annotationEditSlides = {};
1816 updateUndoButton();
1817 updatePendingStatus();
1818 var activeSlide = currentSlide;
1819 loadSlides().then(function () {
1820 if (activeSlide) {
1821 var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(activeSlide) + '"]');
1822 selectSlide(activeSlide, item || undefined);
1823 }
1824 });
1825 }
1826 })
1827 .catch(function (err) {
1828 modalMessage.textContent = t("err_save") + err;
1829 modalConfirm.style.display = "";
1830 modalCancel.style.display = "";
1831 });
1832 });
1833
1834 modalCancel.addEventListener("click", function () {
1835 modalConfirm.textContent = t("modal_submit");
1836 modalOverlay.style.display = "none";
1837 });
1838
1839 // Close modal on overlay click
1840 modalOverlay.addEventListener("click", function (e) {
1841 if (e.target === modalOverlay) {
1842 modalConfirm.textContent = t("modal_submit");
1843 modalOverlay.style.display = "none";
1844 }
1845 });
1846
1847 // ================================================================
1848 // Utility
1849 // ================================================================
1850 function sanitizeSvg(svgString) {
1851 var doc = new DOMParser().parseFromString(svgString, "image/svg+xml");
1852 doc.querySelectorAll("script,foreignObject").forEach(function (el) { el.remove(); });
1853 doc.querySelectorAll("*").forEach(function (el) {
1854 Array.from(el.attributes).forEach(function (attr) {
1855 if (attr.name.indexOf("on") === 0) el.removeAttribute(attr.name);
1856 // Strip dangerous URI protocols from href/xlink:href
1857 if (attr.localName === "href" &&
1858 (/^\s*javascript\s*:/i.test(attr.value) ||
1859 /^\s*data\s*:/i.test(attr.value))) {
1860 el.removeAttributeNode(attr);
1861 }
1862 });
1863 });
1864 return new XMLSerializer().serializeToString(doc.documentElement);
1865 }
1866
1867 function showError(msg) {
1868 var banner = document.createElement("div");
1869 banner.style.cssText = "position:fixed;top:0;left:0;right:0;padding:10px 16px;background:#ef4444;color:#fff;font-size:13px;text-align:center;z-index:999;cursor:pointer;";
1870 banner.textContent = msg;
1871 banner.onclick = function () { banner.remove(); };
1872 document.body.appendChild(banner);
1873 setTimeout(function () { banner.remove(); }, 5000);
1874 }
1875
1876 function showWarning(msg) {
1877 // Amber, non-fatal counterpart to showError. Stacks below an existing
1878 // error banner because z-index is identical and DOM order wins.
1879 var banner = document.createElement("div");
1880 banner.style.cssText = "position:fixed;top:38px;left:0;right:0;padding:8px 16px;background:#f59e0b;color:#1f1300;font-size:12px;text-align:center;z-index:998;cursor:pointer;";
1881 banner.textContent = msg;
1882 banner.onclick = function () { banner.remove(); };
1883 document.body.appendChild(banner);
1884 setTimeout(function () { banner.remove(); }, 6000);
1885 }
1886
1887 function showReloadBanner(name) {
1888 // Singleton: replace any prior banner so we never stack reloads.
1889 hideReloadBanner();
1890 var banner = document.createElement("div");
1891 banner.id = "reload-banner";
1892 banner.style.cssText = "position:fixed;top:0;left:0;right:0;padding:10px 16px;background:#2563eb;color:#fff;font-size:13px;text-align:center;z-index:1000;cursor:pointer;";
1893 banner.textContent = t("reload_banner");
1894 banner.onclick = function () {
1895 hideReloadBanner();
1896 // Re-fetch via selectSlide so all post-load logic (annotation merge,
1897 // warnings, mtime update) runs the same way as a manual click.
1898 var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(name) + '"]');
1899 selectSlide(name, item || undefined);
1900 };
1901 document.body.appendChild(banner);
1902 reloadBannerEl = banner;
1903 }
1904
1905 function hideReloadBanner() {
1906 if (reloadBannerEl) {
1907 reloadBannerEl.remove();
1908 reloadBannerEl = null;
1909 }
1910 }
1911
1912 function escapeHtml(str) {
1913 var d = document.createElement("div");
1914 d.appendChild(document.createTextNode(str));
1915 return d.innerHTML;
1916 }
1917
1918 function jsonOrThrow(res) {
1919 return res.json().then(function (data) {
1920 if (!res.ok || data.error) {
1921 throw new Error(data.error || ("Request failed with status " + res.status));
1922 }
1923 return data;
1924 });
1925 }
1926
1927 function loadConfig() {
1928 return fetch("/api/config")
1929 .then(function (res) { return res.json(); })
1930 .then(function (data) {
1931 liveMode = !!data.live;
1932 })
1933 .catch(function () {
1934 liveMode = false;
1935 });
1936 }
1937
1938 function startSlidePolling() {
1939 if (!liveMode || slidePollTimer) return;
1940 slidePollTimer = window.setInterval(function () {
1941 loadSlides();
1942 }, 2000);
1943 }
1944
1945 // ---- Direct-edit undo + save hint --------------------------------
1946 function updateUndoButton() {
1947 if (!btnUndo) return;
1948 var n = (currentSlide && editStackCount[currentSlide]) || 0;
1949 btnUndo.disabled = n === 0;
1950 btnUndo.textContent = n > 0 ? (t("btn_undo") + " (" + n + ")") : t("btn_undo");
1951 }
1952
1953 function runUndo() {
1954 if (!currentSlide || !editStackCount[currentSlide]) return;
1955 fetch("/api/slide/" + encodeURIComponent(currentSlide) + "/undo", { method: "POST" })
1956 .then(jsonOrThrow)
1957 .then(function (data) {
1958 if (data.status === "empty") {
1959 editStackCount[currentSlide] = 0;
1960 matrixEditSlides[currentSlide] = false;
1961 updateUndoButton();
1962 updatePendingStatus();
1963 showWarning(t("undo_empty"));
1964 return;
1965 }
1966 if (data.mtime !== undefined && data.mtime !== null) {
1967 slideMtimes[currentSlide] = data.mtime;
1968 }
1969 // Reload the page so the canvas reflects the reverted SVG;
1970 // selectSlide refreshes editStackCount + the undo button.
1971 var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(currentSlide) + '"]');
1972 selectSlide(currentSlide, item || undefined);
1973 updatePendingStatus();
1974 showWarning(t("undo_done"));
1975 })
1976 .catch(function (err) {
1977 showError(t("err_edit") + err.message);
1978 });
1979 }
1980
1981 function maybeShowSavedHint() {
1982 if (savedHintShown) return;
1983 savedHintShown = true;
1984 showWarning(t("edit_saved_hint"));
1985 }
1986
1987 function payloadHasMatrixTransform(payload) {
1988 var transform = payload && payload.attrs && payload.attrs.transform;
1989 return typeof transform === "string" && /^\s*matrix\s*\(/i.test(transform);
1990 }
1991
1992 function hasPendingMatrixEdits() {
1993 return Object.keys(matrixEditSlides).some(function (name) {
1994 return !!matrixEditSlides[name];
1995 });
1996 }
1997
1998 function markMatrixTransformEdit(slide) {
1999 matrixEditSlides[slide] = true;
2000 if (matrixHintShown) return;
2001 matrixHintShown = true;
2002 showWarning(t("warn_matrix_transform"));
2003 }
2004
2005 // ================================================================
2006 // Property extraction & rendering
2007 // ================================================================
2008 // Editable property panel for a single selected element: computed geometry,
2009 // text content where safe, and the element's own SVG attributes.
2010 function renderEditableProps(el) {
2011 var tag = localName(el);
2012 var isGroup = tag === "g";
2013 var html = '<div class="prop-caption">' +
2014 escapeHtml(isGroup ? t("label_group_edit") : t("label_direct_edit")) + '</div>';
2015
2016 var parentGroup = nearestParentGroup(el);
2017 if (parentGroup && !isGroup) {
2018 html += '<table class="prop-table"><tr><td class="prop-key">group</td><td class="prop-val">' +
2019 '<button type="button" class="btn-select-group" data-group-id="' +
2020 escapeHtml(parentGroup.id) + '">' + escapeHtml(t("btn_select_group")) +
2021 '</button></td></tr></table>';
2022 }
2023
2024 html += '<div class="prop-section">' + escapeHtml(t("section_geometry")) + '</div>';
2025 html += '<table class="prop-table">';
2026 // Geometry: position + size in render coords — editable, folded into
2027 // the element's transform matrix on commit (works for any element type).
2028 try {
2029 var rb = computeRenderBox(el);
2030 html += '<tr><td class="prop-key">position</td><td class="prop-val geo-cell">' +
2031 '<input type="number" class="prop-edit prop-edit-geo" data-geo="x" value="' + Math.round(rb.x) + '">' +
2032 '<input type="number" class="prop-edit prop-edit-geo" data-geo="y" value="' + Math.round(rb.y) + '">' +
2033 '</td></tr>';
2034 html += '<tr><td class="prop-key">size</td><td class="prop-val geo-cell">' +
2035 '<input type="number" class="prop-edit prop-edit-geo" data-geo="w" min="1" value="' + Math.round(rb.w) + '">' +
2036 '<input type="number" class="prop-edit prop-edit-geo" data-geo="h" min="1" value="' + Math.round(rb.h) + '">' +
2037 '</td></tr>';
2038 } catch (e) { /* no geometry */ }
2039 html += '</table>';
2040
2041 var textStyleTargets = textStyleTargetsForSelection(el);
2042
2043 // L1: text content + computed text styles. When a textbox/group is
2044 // selected, style controls target its descendant text leaves.
2045 if (tag === "text" || tag === "tspan" || textStyleTargets.length > 0) {
2046 html += '<div class="prop-section">' + escapeHtml(t("section_text_style")) + '</div>';
2047 html += '<table class="prop-table">';
2048 if ((tag === "text" || tag === "tspan") && el.querySelector("tspan")) {
2049 html += '<tr><td class="prop-key">content</td><td class="prop-val prop-note">' +
2050 escapeHtml(t("prop_multiline_hint")) + '</td></tr>';
2051 } else if (tag === "text" || tag === "tspan") {
2052 html += '<tr><td class="prop-key">content</td><td class="prop-val">' +
2053 '<textarea class="prop-edit prop-edit-content" rows="2">' +
2054 escapeHtml(el.textContent || "") + '</textarea></td></tr>';
2055 }
2056 if (textStyleTargets.length > 0) {
2057 textStyleSpecs(textStyleTargets).forEach(function (spec) {
2058 html += '<tr><td class="prop-key">' + escapeHtml(spec.key) + '</td><td class="prop-val">';
2059 html += renderTextStyleControl(spec, textStyleTargets);
2060 html += '</td></tr>';
2061 });
2062 }
2063 html += '</table>';
2064 }
2065
2066 html += '<div class="prop-section">' + escapeHtml(t("section_raw_attrs")) + '</div>';
2067 html += '<table class="prop-table">';
2068 ownAttributeSpecs(el).forEach(function (spec) {
2069 html += '<tr><td class="prop-key">' + escapeHtml(spec.key) + '</td><td class="prop-val">';
2070 if (!spec.editable) {
2071 html += '<span class="prop-ro">' + escapeHtml(spec.value) + '</span>';
2072 } else {
2073 html += renderAttributeControl(spec);
2074 }
2075 html += '</td></tr>';
2076 });
2077
2078 html += '</table>';
2079 return html;
2080 }
2081
2082 function nearestParentGroup(el) {
2083 var node = el ? el.parentElement : null;
2084 while (node && node !== svgContent) {
2085 if (node.tagName && localName(node) === "g" && node.id) return node;
2086 node = node.parentElement;
2087 }
2088 return null;
2089 }
2090
2091 function ownAttributeSpecs(el) {
2092 var specs = [];
2093 Array.from(el.attributes).forEach(function (attr) {
2094 var key = attr.name;
2095 if (key === "class" || key === "data-edit-target" || key === "data-edit-annotation") return;
2096 specs.push({
2097 key: key,
2098 value: attr.value,
2099 editable: isEditableAttributeName(key)
2100 });
2101 });
2102 return specs;
2103 }
2104
2105 function isEditableAttributeName(key) {
2106 var k = String(key).toLowerCase();
2107 if (k === "id" || k === "class" || k === "href" || k === "xlink:href") return false;
2108 if (k.indexOf("on") === 0) return false;
2109 return /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(key);
2110 }
2111
2112 function textStyleTargetsForSelection(el) {
2113 if (!el) return [];
2114 var tag = localName(el);
2115 if (tag === "tspan") return el.id ? [el] : [];
2116 if (tag === "text") return textStyleLeavesForText(el);
2117
2118 var targets = [];
2119 el.querySelectorAll("text").forEach(function (textEl) {
2120 targets = targets.concat(textStyleLeavesForText(textEl));
2121 });
2122 return uniqueElements(targets);
2123 }
2124
2125 function textStyleLeavesForText(textEl) {
2126 var tspans = Array.from(textEl.querySelectorAll("tspan")).filter(function (tspan) {
2127 return !!tspan.id;
2128 });
2129 if (tspans.length > 0) return tspans;
2130 return textEl.id ? [textEl] : [];
2131 }
2132
2133 function uniqueElements(elements) {
2134 var seen = new Set();
2135 return elements.filter(function (el) {
2136 if (!el || !el.id || seen.has(el.id)) return false;
2137 seen.add(el.id);
2138 return true;
2139 });
2140 }
2141
2142 function textStyleSpecs(targets) {
2143 return [
2144 { key: "fill", type: "color" },
2145 { key: "font-size", type: "text" },
2146 { key: "font-family", type: "text" },
2147 { key: "font-weight", type: "select",
2148 options: ["", "normal", "bold", "300", "400", "500", "600", "700", "800"] },
2149 { key: "text-anchor", type: "select", options: ["", "start", "middle", "end"] }
2150 ].map(function (spec) {
2151 var value = commonAttrValue(targets, spec.key);
2152 var copy = Object.assign({}, spec);
2153 copy.value = value === null ? "" : value;
2154 copy.mixed = value === null;
2155 return copy;
2156 });
2157 }
2158
2159 function renderTextStyleControl(spec, targets) {
2160 var targetIds = encodeURIComponent(JSON.stringify(targets.map(function (el) { return el.id; })));
2161 var placeholder = spec.mixed ? t("multi_mixed") : "";
2162 if (spec.type === "color") {
2163 return '<input type="color" class="prop-edit prop-edit-text-style-color" data-key="' +
2164 escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
2165 '" value="' + normalizeHexForPicker(spec.value) + '">' +
2166 '<input type="text" class="prop-edit prop-edit-text-style" data-key="' +
2167 escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
2168 '" placeholder="' + escapeHtml(placeholder) +
2169 '" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
2170 }
2171 if (spec.type === "select") {
2172 var html = '<select class="prop-edit prop-edit-text-style" data-key="' +
2173 escapeHtml(spec.key) + '" data-target-ids="' + targetIds + '">';
2174 var hasValue = spec.options.indexOf(spec.value) !== -1;
2175 if (!spec.mixed && spec.value && !hasValue) {
2176 html += '<option value="' + escapeHtml(spec.value) + '" selected>' +
2177 escapeHtml(spec.value) + '</option>';
2178 }
2179 spec.options.forEach(function (opt) {
2180 var label = opt || placeholder || "";
2181 var selected = !spec.mixed && spec.value === opt ? " selected" : "";
2182 html += '<option value="' + escapeHtml(opt) + '"' + selected + '>' +
2183 escapeHtml(label) + '</option>';
2184 });
2185 html += '</select>';
2186 return html;
2187 }
2188 return '<input type="text" class="prop-edit prop-edit-text-style" data-key="' +
2189 escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
2190 '" placeholder="' + escapeHtml(placeholder) +
2191 '" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
2192 }
2193
2194 function isColorAttribute(key, value) {
2195 var k = String(key).toLowerCase();
2196 if (["fill", "stroke", "color", "stop-color", "flood-color", "lighting-color"].indexOf(k) !== -1) {
2197 return isSafeColor(value);
2198 }
2199 return false;
2200 }
2201
2202 function renderAttributeControl(spec) {
2203 if (isColorAttribute(spec.key, spec.value)) {
2204 return '<input type="color" class="prop-edit prop-edit-color" data-key="' +
2205 escapeHtml(spec.key) + '" value="' + normalizeHexForPicker(spec.value) + '">' +
2206 '<input type="text" class="prop-edit prop-edit-color-text" data-key="' +
2207 escapeHtml(spec.key) + '" value="' + escapeHtml(spec.value) + '">';
2208 }
2209 if (spec.value.length > 60 || spec.key === "d" || spec.key === "points" || spec.key === "style") {
2210 return '<textarea class="prop-edit prop-edit-attr-area" rows="2" data-key="' +
2211 escapeHtml(spec.key) + '">' + escapeHtml(spec.value) + '</textarea>';
2212 }
2213 return '<input type="text" class="prop-edit prop-edit-attr" data-key="' +
2214 escapeHtml(spec.key) + '" value="' + escapeHtml(spec.value) + '">';
2215 }
2216
2217 // ---- Geometry (position/size) helpers ----------------------------
2218 // Edits fold into the element's own transform matrix so they apply
2219 // uniformly regardless of how the element is positioned (x/y, cx/cy, d…).
2220
2221 function computeRenderBox(el) {
2222 // Local geometry bbox (excludes the element's OWN transform) ...
2223 var bbox = el.getBBox();
2224 // ... times the element CTM (includes own + ancestor transforms) =
2225 // the on-screen box in the svg root user space (= svg_output coords).
2226 var ctm = el.getCTM();
2227 if (!ctm) return { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height };
2228 var corners = [
2229 { x: bbox.x, y: bbox.y },
2230 { x: bbox.x + bbox.width, y: bbox.y },
2231 { x: bbox.x, y: bbox.y + bbox.height },
2232 { x: bbox.x + bbox.width, y: bbox.y + bbox.height }
2233 ];
2234 var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2235 corners.forEach(function (p) {
2236 var sx = p.x * ctm.a + p.y * ctm.c + ctm.e;
2237 var sy = p.x * ctm.b + p.y * ctm.d + ctm.f;
2238 minX = Math.min(minX, sx); minY = Math.min(minY, sy);
2239 maxX = Math.max(maxX, sx); maxY = Math.max(maxY, sy);
2240 });
2241 return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
2242 }
2243
2244 function ownMatrix(el) {
2245 var t = el.transform && el.transform.baseVal;
2246 if (t && t.numberOfItems) {
2247 var c = t.consolidate();
2248 if (c) {
2249 var m = c.matrix;
2250 return new DOMMatrix([m.a, m.b, m.c, m.d, m.e, m.f]);
2251 }
2252 }
2253 return new DOMMatrix();
2254 }
2255
2256 function parentCTM(el) {
2257 // parent → svg-root user space; identity when parent is the outer <svg>.
2258 var p = el.parentNode;
2259 var pm = (p && p.getCTM) ? p.getCTM() : null;
2260 return pm ? new DOMMatrix([pm.a, pm.b, pm.c, pm.d, pm.e, pm.f]) : new DOMMatrix();
2261 }
2262
2263 function matrixAfterMove(el, rb, nx, ny) {
2264 // Desired shift in root coords, mapped into parent space (linear part only).
2265 var pm = parentCTM(el);
2266 var rx = nx - rb.x, ry = ny - rb.y;
2267 var det = pm.a * pm.d - pm.b * pm.c || 1;
2268 var dpx = (pm.d * rx - pm.c * ry) / det;
2269 var dpy = (-pm.b * rx + pm.a * ry) / det;
2270 return new DOMMatrix().translate(dpx, dpy).multiply(ownMatrix(el));
2271 }
2272
2273 function matrixAfterResize(el, rb, nw, nh) {
2274 var pm = parentCTM(el);
2275 var sx = nw / rb.w, sy = nh / rb.h;
2276 // Anchor = render top-left, mapped to parent coords (full inverse).
2277 var im = pm.inverse();
2278 var ax = im.a * rb.x + im.c * rb.y + im.e;
2279 var ay = im.b * rb.x + im.d * rb.y + im.f;
2280 return new DOMMatrix()
2281 .translate(ax, ay).scale(sx, sy).translate(-ax, -ay)
2282 .multiply(ownMatrix(el));
2283 }
2284
2285 function matrixToString(m) {
2286 var r = function (v) { return Math.round(v * 1e6) / 1e6; };
2287 var values = [m.a, m.b, m.c, m.d, m.e, m.f].map(r);
2288 if (!values.every(function (v) { return Number.isFinite(v); })) return null;
2289 return "matrix(" + values.join(",") + ")";
2290 }
2291
2292 function refreshGeoInputs(el, panel) {
2293 var rb = computeRenderBox(el);
2294 var vals = { x: Math.round(rb.x), y: Math.round(rb.y), w: Math.round(rb.w), h: Math.round(rb.h) };
2295 Object.keys(vals).forEach(function (k) {
2296 var inp = panel.querySelector('.prop-edit-geo[data-geo="' + k + '"]');
2297 if (inp && document.activeElement !== inp) inp.value = vals[k];
2298 });
2299 }
2300
2301 function normalizeHexForPicker(val) {
2302 var s = String(val).trim();
2303 if (/^#[0-9a-fA-F]{6}$/.test(s)) return s;
2304 if (/^#[0-9a-fA-F]{3}$/.test(s)) {
2305 return "#" + s[1] + s[1] + s[2] + s[2] + s[3] + s[3];
2306 }
2307 return "#000000";
2308 }
2309
2310 var SVG_UNIT_OPTIONAL_LENGTH_ATTRS = new Set([
2311 "x", "y", "x1", "x2", "y1", "y2",
2312 "cx", "cy", "r", "rx", "ry",
2313 "dx", "dy", "width", "height",
2314 "font-size", "stroke-width", "stroke-dashoffset",
2315 "letter-spacing", "word-spacing"
2316 ]);
2317
2318 function normalizeSvgLengthValue(key, value) {
2319 var s = String(value || "");
2320 if (!SVG_UNIT_OPTIONAL_LENGTH_ATTRS.has(String(key).toLowerCase())) return s;
2321 s = s.trim();
2322 var m = s.match(/^(-?\d+(?:\.\d+)?)px$/i);
2323 return m ? m[1] : s;
2324 }
2325
2326 function normalizeEditableAttrValue(key, value) {
2327 var s = String(value || "").trim();
2328 return normalizeSvgLengthValue(key, s);
2329 }
2330
2331 function isSafeColor(val) {
2332 // Only allow values that look like CSS colors (hex, rgb, rgba, hsl, named).
2333 // Reject anything with ; : url @ \ to prevent CSS injection.
2334 return val.length < 100 && !/[;:@\\]|url\s*\(/i.test(val);
2335 }
2336
2337 function splitNumberUnit(value) {
2338 var m = String(value || "").trim().match(/^(-?\d+(?:\.\d+)?)([A-Za-z%]*)$/);
2339 if (!m) return null;
2340 return { num: parseFloat(m[1]), unit: m[2] || "" };
2341 }
2342
2343 function shiftedAttrValue(el, key, delta) {
2344 var parsed = splitNumberUnit(el.getAttribute(key));
2345 if (!parsed || !Number.isFinite(parsed.num)) return null;
2346 var next = Math.round((parsed.num + delta) * 1000) / 1000;
2347 return String(next) + parsed.unit;
2348 }
2349
2350 function parentDelta(dx, dy, el) {
2351 var pm = parentCTM(el);
2352 var det = pm.a * pm.d - pm.b * pm.c;
2353 if (Math.abs(det) < 1e-9) return null;
2354 return {
2355 x: (pm.d * dx - pm.c * dy) / det,
2356 y: (-pm.b * dx + pm.a * dy) / det
2357 };
2358 }
2359
2360 function firstNumber(value) {
2361 var m = String(value || "").match(/-?\d+(?:\.\d+)?/);
2362 return m ? parseFloat(m[0]) : null;
2363 }
2364
2365 function formatCoord(value) {
2366 return String(Math.round(value * 1000) / 1000);
2367 }
2368
2369 function tspanBaseline(tspan) {
2370 var text = tspan && tspan.parentElement;
2371 if (!text || localName(text) !== "text") return null;
2372 var curX = firstNumber(text.getAttribute("x"));
2373 var curY = firstNumber(text.getAttribute("y"));
2374 var children = Array.from(text.children);
2375 for (var i = 0; i < children.length; i++) {
2376 var child = children[i];
2377 if (localName(child) !== "tspan") continue;
2378 var x = firstNumber(child.getAttribute("x"));
2379 var y = firstNumber(child.getAttribute("y"));
2380 var dx = firstNumber(child.getAttribute("dx"));
2381 var dy = firstNumber(child.getAttribute("dy"));
2382 if (x !== null) curX = x;
2383 else if (dx !== null) curX = (curX || 0) + dx;
2384 if (y !== null) curY = y;
2385 else if (dy !== null) curY = (curY || 0) + dy;
2386 if (child === tspan) break;
2387 }
2388 if (curX === null || curY === null) return null;
2389 return { x: curX, y: curY };
2390 }
2391
2392 function tspanPromotionPayload(tspan) {
2393 if (localName(tspan) !== "tspan") return null;
2394 var base = tspanBaseline(tspan);
2395 if (!base) return null;
2396 var own = ownMatrix(tspan);
2397 var close = function (a, b) { return Math.abs(a - b) < 1e-6; };
2398 if (!close(own.a, 1) || !close(own.b, 0) ||
2399 !close(own.c, 0) || !close(own.d, 1)) return null;
2400 return {
2401 x: formatCoord(base.x + own.e),
2402 y: formatCoord(base.y + own.f)
2403 };
2404 }
2405
2406 function copyTextAttrs(src, dst, skip) {
2407 Array.from(src.attributes || []).forEach(function (attr) {
2408 if (skip[attr.name]) return;
2409 dst.setAttribute(attr.name, attr.value);
2410 });
2411 }
2412
2413 function adjustedDyValue(oldValue, nextY, prevY) {
2414 var parsed = splitNumberUnit(oldValue);
2415 if (!parsed) return formatCoord(nextY - prevY);
2416 return formatCoord(nextY - prevY) + parsed.unit;
2417 }
2418
2419 function promoteTspanDom(tspan, promoted) {
2420 var text = tspan && tspan.parentElement;
2421 if (!text || localName(tspan) !== "tspan" || localName(text) !== "text") return null;
2422 var next = tspan.nextElementSibling;
2423 var nextBase = next && localName(next) === "tspan" ? tspanBaseline(next) : null;
2424 var prev = tspan.previousElementSibling;
2425 var prevBase = prev && localName(prev) === "tspan" ? tspanBaseline(prev) : null;
2426 var parentBaseY = firstNumber(text.getAttribute("y")) || 0;
2427
2428 var newText = document.createElementNS(tspan.namespaceURI, "text");
2429 copyTextAttrs(text, newText, { id: true, x: true, y: true, dx: true, dy: true });
2430 copyTextAttrs(tspan, newText, {
2431 id: true, x: true, y: true, dx: true, dy: true, transform: true
2432 });
2433 newText.setAttribute("id", tspan.id);
2434 newText.setAttribute("x", promoted.x);
2435 newText.setAttribute("y", promoted.y);
2436
2437 Array.from(tspan.childNodes || []).forEach(function (node) {
2438 newText.appendChild(node.cloneNode(true));
2439 });
2440 if (newText.childNodes.length === 0) {
2441 newText.textContent = tspan.textContent || "";
2442 }
2443
2444 var textParent = text.parentNode;
2445 var textNext = text.nextSibling;
2446 var textClone = text.cloneNode(true);
2447 newText.__promotionRollback = function () {
2448 if (newText.parentNode) newText.parentNode.removeChild(newText);
2449 if (text.parentNode) text.parentNode.removeChild(text);
2450 if (textParent) {
2451 if (textNext && textNext.parentNode === textParent) {
2452 textParent.insertBefore(textClone, textNext);
2453 } else {
2454 textParent.appendChild(textClone);
2455 }
2456 }
2457 selectedElementIds.delete(newText.id);
2458 selectedElementIds.add(tspan.id);
2459 markSelection();
2460 return textClone.querySelector("#" + CSS.escape(tspan.id)) || textClone;
2461 };
2462
2463 text.parentNode.insertBefore(newText, text.nextSibling);
2464 text.removeChild(tspan);
2465 if (next && nextBase && !next.hasAttribute("y") && next.hasAttribute("dy")) {
2466 next.setAttribute("dy", adjustedDyValue(
2467 next.getAttribute("dy"),
2468 nextBase.y,
2469 prevBase ? prevBase.y : parentBaseY
2470 ));
2471 }
2472 if (!(text.textContent || "").trim() && text.children.length === 0) {
2473 text.parentNode.removeChild(text);
2474 }
2475 selectedElementIds.delete(tspan.id);
2476 selectedElementIds.add(newText.id);
2477 markSelection();
2478 return newText;
2479 }
2480
2481 function payloadForMovedElement(el, attrs) {
2482 var promoted = tspanPromotionPayload(el);
2483 if (promoted) return { promote_tspan: promoted };
2484 return { attrs: attrs };
2485 }
2486
2487 function canUseRawGeometry(el) {
2488 var own = ownMatrix(el);
2489 var close = function (a, b) { return Math.abs(a - b) < 1e-6; };
2490 if (close(own.b, 0) && close(own.c, 0)) {
2491 if (close(own.a, 1) && close(own.d, 1)) return "translate";
2492 if (localName(el) === "use") return "use-transform";
2493 if (["rect", "image"].indexOf(localName(el)) !== -1) return "box-transform";
2494 }
2495 return "";
2496 }
2497
2498 function rawMoveAttrs(el, dx, dy) {
2499 if (localName(el) === "g" && el.hasAttribute("data-icon") &&
2500 el.hasAttribute("data-use-x") && el.hasAttribute("data-use-y")) {
2501 var iconOwn = ownMatrix(el);
2502 var close = function (a, b) { return Math.abs(a - b) < 1e-6; };
2503 if (close(iconOwn.b, 0) && close(iconOwn.c, 0)) {
2504 var iconDelta = parentDelta(dx, dy, el);
2505 if (iconDelta) {
2506 var iconAttrs = {
2507 x: formatCoord(iconOwn.e + iconDelta.x),
2508 y: formatCoord(iconOwn.f + iconDelta.y)
2509 };
2510 if (el.hasAttribute("data-use-has-transform")) {
2511 iconAttrs.transform = null;
2512 }
2513 return iconAttrs;
2514 }
2515 }
2516 }
2517
2518 var rawMode = canUseRawGeometry(el);
2519 if (!rawMode) return null;
2520 var d = parentDelta(dx, dy, el);
2521 if (!d) return null;
2522 var tag = localName(el);
2523 var attrs = {};
2524 var own = ownMatrix(el);
2525 var add = function (key, delta) {
2526 if (!el.hasAttribute(key)) return false;
2527 var next = shiftedAttrValue(el, key, delta);
2528 if (next === null) return false;
2529 attrs[key] = next;
2530 return true;
2531 };
2532 var fmt = function (value) {
2533 return String(Math.round(value * 1000) / 1000);
2534 };
2535 var bakeTranslate = function (xKey, yKey) {
2536 if (!el.hasAttribute(xKey) || !el.hasAttribute(yKey)) return false;
2537 if (rawMode === "use-transform") {
2538 attrs[xKey] = fmt(own.e + d.x);
2539 attrs[yKey] = fmt(own.f + d.y);
2540 attrs.transform = null;
2541 return true;
2542 }
2543 var nx = shiftedAttrValue(el, xKey, own.e + d.x);
2544 var ny = shiftedAttrValue(el, yKey, own.f + d.y);
2545 if (nx === null || ny === null) return false;
2546 attrs[xKey] = nx;
2547 attrs[yKey] = ny;
2548 if (el.hasAttribute("transform")) attrs.transform = null;
2549 return true;
2550 };
2551 var bakeBox = function () {
2552 if (!el.hasAttribute("x") || !el.hasAttribute("y") ||
2553 !el.hasAttribute("width") || !el.hasAttribute("height")) return false;
2554 var x = splitNumberUnit(el.getAttribute("x"));
2555 var y = splitNumberUnit(el.getAttribute("y"));
2556 var w = splitNumberUnit(el.getAttribute("width"));
2557 var h = splitNumberUnit(el.getAttribute("height"));
2558 if (!x || !y || !w || !h || x.unit !== y.unit ||
2559 x.unit !== w.unit || x.unit !== h.unit) return false;
2560 attrs.x = fmt(own.a * x.num + own.e + d.x) + x.unit;
2561 attrs.y = fmt(own.d * y.num + own.f + d.y) + y.unit;
2562 attrs.width = fmt(Math.abs(own.a) * w.num) + w.unit;
2563 attrs.height = fmt(Math.abs(own.d) * h.num) + h.unit;
2564 attrs.transform = null;
2565 return true;
2566 };
2567
2568 if (rawMode === "box-transform") {
2569 return bakeBox() ? attrs : null;
2570 }
2571 if (["rect", "image", "use", "text"].indexOf(tag) !== -1) {
2572 return bakeTranslate("x", "y") ? attrs : null;
2573 }
2574 if (tag === "circle" || tag === "ellipse") {
2575 return bakeTranslate("cx", "cy") ? attrs : null;
2576 }
2577 if (tag === "line") {
2578 if (add("x1", own.e + d.x) && add("x2", own.e + d.x) &&
2579 add("y1", own.f + d.y) && add("y2", own.f + d.y)) {
2580 if (el.hasAttribute("transform")) attrs.transform = null;
2581 return attrs;
2582 }
2583 return null;
2584 }
2585 return null;
2586 }
2587
2588 function attrsForMove(el, dx, dy) {
2589 var attrs = rawMoveAttrs(el, dx, dy);
2590 if (attrs) return attrs;
2591 var rb = computeRenderBox(el);
2592 var moved = matrixAfterMove(el, rb, rb.x + dx, rb.y + dy);
2593 var tstr = matrixToString(moved);
2594 return tstr ? { transform: tstr } : null;
2595 }
2596
2597 function computeElementsBox(elements) {
2598 var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2599 elements.forEach(function (el) {
2600 var rb = computeRenderBox(el);
2601 minX = Math.min(minX, rb.x);
2602 minY = Math.min(minY, rb.y);
2603 maxX = Math.max(maxX, rb.x + rb.w);
2604 maxY = Math.max(maxY, rb.y + rb.h);
2605 });
2606 return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
2607 }
2608
2609 function topLevelSelectedElements(ids) {
2610 var els = ids.map(function (eid) {
2611 return svgContent.querySelector("#" + CSS.escape(eid));
2612 }).filter(Boolean);
2613 return els.filter(function (el) {
2614 return !els.some(function (other) {
2615 return other !== el && other.contains(el);
2616 });
2617 });
2618 }
2619
2620 function attachPropEditors(el) {
2621 var eid = el.id;
2622 var panel = elementPropsEl;
2623
2624 var groupBtn = panel.querySelector(".btn-select-group");
2625 if (groupBtn) {
2626 groupBtn.addEventListener("click", function () {
2627 var gid = groupBtn.getAttribute("data-group-id");
2628 var groupEl = gid ? svgContent.querySelector("#" + CSS.escape(gid)) : null;
2629 if (groupEl) selectElement(groupEl, false);
2630 });
2631 }
2632
2633 // L1: text content (fires on blur/change).
2634 var contentBox = panel.querySelector(".prop-edit-content");
2635 if (contentBox) {
2636 contentBox.addEventListener("change", function () {
2637 var newText = contentBox.value;
2638 stageEditRequest(eid, { text: newText })
2639 .then(function () { el.textContent = newText; })
2640 .catch(function (err) { showError(t("err_edit") + err.message); });
2641 });
2642 }
2643
2644 // Raw own attributes: color picker + text input stay in sync.
2645 panel.querySelectorAll(".prop-edit-color").forEach(function (picker) {
2646 var key = picker.getAttribute("data-key");
2647 var textInput = panel.querySelector('.prop-edit-color-text[data-key="' + key + '"]');
2648 picker.addEventListener("input", function () {
2649 if (textInput) textInput.value = picker.value;
2650 });
2651 picker.addEventListener("change", function () {
2652 stageAttr(el, eid, key, picker.value);
2653 });
2654 });
2655 panel.querySelectorAll(".prop-edit-color-text").forEach(function (textInput) {
2656 var key = textInput.getAttribute("data-key");
2657 var picker = panel.querySelector('input.prop-edit-color[data-key="' + key + '"]');
2658 textInput.addEventListener("change", function () {
2659 var v = textInput.value.trim();
2660 if (picker) picker.value = normalizeHexForPicker(v);
2661 stageAttr(el, eid, key, v);
2662 });
2663 });
2664
2665 panel.querySelectorAll(".prop-edit-text-style-color").forEach(function (picker) {
2666 var key = picker.getAttribute("data-key");
2667 var textInput = panel.querySelector('.prop-edit-text-style[data-key="' + key + '"]');
2668 picker.addEventListener("input", function () {
2669 if (textInput) textInput.value = picker.value;
2670 });
2671 picker.addEventListener("change", function () {
2672 if (textInput) textInput.value = picker.value;
2673 stageTextStyleAttr(key, picker.value, picker.getAttribute("data-target-ids"));
2674 });
2675 });
2676 panel.querySelectorAll(".prop-edit-text-style").forEach(function (input) {
2677 input.addEventListener("change", function () {
2678 var key = input.getAttribute("data-key");
2679 var value = normalizeEditableAttrValue(key, input.value);
2680 if (!value) return;
2681 stageTextStyleAttr(key, value, input.getAttribute("data-target-ids"));
2682 });
2683 });
2684
2685 panel.querySelectorAll(".prop-edit-attr, .prop-edit-attr-area").forEach(function (input) {
2686 var key = input.getAttribute("data-key");
2687 input.addEventListener("change", function () {
2688 stageAttr(el, eid, key, normalizeEditableAttrValue(key, input.value));
2689 });
2690 });
2691
2692 // Geometry: position (move) + size (resize), folded into the transform.
2693 if (panel.querySelector(".prop-edit-geo")) {
2694 var readGeo = function (k) {
2695 var inp = panel.querySelector('.prop-edit-geo[data-geo="' + k + '"]');
2696 return inp ? parseFloat(inp.value) : NaN;
2697 };
2698 var commitGeo = function (mode) {
2699 var rb;
2700 try { rb = computeRenderBox(el); } catch (e) { return; }
2701 var newM;
2702 var attrs = null;
2703 if (mode === "move") {
2704 var nx = readGeo("x"), ny = readGeo("y");
2705 if (isNaN(nx) || isNaN(ny)) return;
2706 if (Math.abs(nx - rb.x) < 1e-6 && Math.abs(ny - rb.y) < 1e-6) return;
2707 attrs = attrsForMove(el, nx - rb.x, ny - rb.y);
2708 if (!attrs) newM = matrixAfterMove(el, rb, nx, ny);
2709 } else {
2710 var nw = readGeo("w"), nh = readGeo("h");
2711 if (isNaN(nw) || isNaN(nh) || nw <= 0 || nh <= 0 || rb.w <= 0 || rb.h <= 0) {
2712 showError(t("err_edit") + "size");
2713 return;
2714 }
2715 if (Math.abs(nw - rb.w) < 1e-6 && Math.abs(nh - rb.h) < 1e-6) return;
2716 newM = matrixAfterResize(el, rb, nw, nh);
2717 }
2718 if (!attrs) {
2719 var tstr = matrixToString(newM);
2720 if (!tstr) {
2721 showError(t("err_edit") + "transform");
2722 return;
2723 }
2724 attrs = { transform: tstr };
2725 }
2726 var payload = mode === "move" ? payloadForMovedElement(el, attrs) : { attrs: attrs };
2727 stageEditRequest(eid, payload)
2728 .then(function () {
2729 if (payload.promote_tspan) {
2730 el = promoteTspanDom(el, payload.promote_tspan) || el;
2731 } else {
2732 applyElementAttrs(el, attrs);
2733 markCommittedAttrs(el, attrs);
2734 }
2735 refreshGeoInputs(el, panel);
2736 })
2737 .catch(function (err) { showError(t("err_edit") + err.message); });
2738 };
2739 panel.querySelectorAll('.prop-edit-geo[data-geo="x"], .prop-edit-geo[data-geo="y"]').forEach(function (inp) {
2740 inp.addEventListener("change", function () { commitGeo("move"); });
2741 });
2742 panel.querySelectorAll('.prop-edit-geo[data-geo="w"], .prop-edit-geo[data-geo="h"]').forEach(function (inp) {
2743 inp.addEventListener("change", function () { commitGeo("resize"); });
2744 });
2745 }
2746 }
2747
2748 function stageTextStyleAttr(key, value, encodedTargetIds) {
2749 value = normalizeEditableAttrValue(key, value);
2750 if ((key === "fill" || key === "stroke") && !isSafeColor(value)) {
2751 showError(t("err_edit") + key);
2752 return;
2753 }
2754 var ids;
2755 try {
2756 ids = JSON.parse(decodeURIComponent(encodedTargetIds || "%5B%5D"));
2757 } catch (e) {
2758 ids = [];
2759 }
2760 var targets = ids.map(function (id) {
2761 return svgContent.querySelector("#" + CSS.escape(id));
2762 }).filter(function (target) {
2763 return target && attrOrComputedValue(target, key) !== value;
2764 });
2765 if (targets.length === 0) return;
2766
2767 var attrs = {};
2768 attrs[key] = value;
2769 var jobs = targets.map(function (target) {
2770 return stageEditRequest(target.id, { attrs: attrs }).then(function () {
2771 target.setAttribute(key, value);
2772 });
2773 });
2774 Promise.all(jobs)
2775 .then(function () { updateSelectionPanel(); })
2776 .catch(function (err) { showError(t("err_edit") + err.message); });
2777 }
2778
2779 function stageAttr(el, eid, key, value) {
2780 value = normalizeEditableAttrValue(key, value);
2781 if ((key === "fill" || key === "stroke") && !isSafeColor(value)) {
2782 showError(t("err_edit") + key);
2783 return;
2784 }
2785 var attrs = {};
2786 attrs[key] = value;
2787 stageEditRequest(eid, { attrs: attrs })
2788 .then(function () { applyElementAttrs(el, attrs); })
2789 .catch(function (err) { showError(t("err_edit") + err.message); });
2790 }
2791
2792 // Tracks every in-flight direct-edit POST so Apply changes can wait for them
2793 // to land before /save-all (otherwise a just-sent edit could arrive after the
2794 // save and resurrect a pending edit).
2795 var inFlightEdits = new Set();
2796
2797 function drainDirectEdits() {
2798 if (inFlightEdits.size === 0) return Promise.resolve();
2799 return Promise.allSettled(Array.from(inFlightEdits)).then(function (results) {
2800 var failed = results.find(function (r) { return r.status === "rejected"; });
2801 if (failed) throw (failed.reason || new Error("direct edit failed"));
2802 return drainDirectEdits();
2803 });
2804 }
2805
2806 function stageEditRequest(eid, payload, slideName) {
2807 // Pin the slide at call time: a queued nudge may fire after the user has
2808 // navigated, and must still POST to the slide it was made on, not the
2809 // global currentSlide.
2810 var slide = slideName || currentSlide;
2811 var body = { element_id: eid };
2812 if (payload.text !== undefined) body.text = payload.text;
2813 if (payload.attrs !== undefined) body.attrs = payload.attrs;
2814 if (payload.promote_tspan !== undefined) body.promote_tspan = payload.promote_tspan;
2815 var p = fetch("/api/slide/" + encodeURIComponent(slide) + "/edit", {
2816 method: "POST",
2817 headers: { "Content-Type": "application/json" },
2818 body: JSON.stringify(body)
2819 }).then(jsonOrThrow).then(function (data) {
2820 if (data && data.undo_depth !== undefined) {
2821 editStackCount[slide] = data.undo_depth;
2822 if (slide === currentSlide) updateUndoButton();
2823 updatePendingStatus();
2824 }
2825 if (payloadHasMatrixTransform(payload)) {
2826 markMatrixTransformEdit(slide);
2827 }
2828 maybeShowSavedHint();
2829 return data;
2830 });
2831 inFlightEdits.add(p);
2832 var done = function () { inFlightEdits.delete(p); };
2833 p.then(done, done);
2834 return p;
2835 }
2836
2837 function renderMultiSelectSummary(ids) {
2838 var moveEls = topLevelSelectedElements(ids);
2839 var summary = '<div class="prop-caption">' + escapeHtml(t("label_batch_edit")) + '</div>';
2840
2841 if (moveEls.length > 0) {
2842 try {
2843 var rb = computeElementsBox(moveEls);
2844 summary += '<div class="prop-section">' + escapeHtml(t("section_geometry")) + '</div>';
2845 summary += '<table class="prop-table"><tr><td class="prop-key">x / y</td>' +
2846 '<td class="prop-val geo-cell">' +
2847 '<input type="number" class="prop-edit prop-edit-multi-geo" data-geo="x" value="' +
2848 Math.round(rb.x) + '">' +
2849 '<input type="number" class="prop-edit prop-edit-multi-geo" data-geo="y" value="' +
2850 Math.round(rb.y) + '">' +
2851 '</td></tr></table>';
2852 } catch (e) { /* no multi geometry */ }
2853 }
2854 summary += '<div class="prop-section">' + escapeHtml(t("section_style")) + '</div>';
2855 summary += '<table class="prop-table">';
2856 batchEditSpecs(moveEls).forEach(function (spec) {
2857 summary += '<tr><td class="prop-key">' + escapeHtml(spec.key) + '</td><td class="prop-val">';
2858 summary += renderBatchControl(spec);
2859 summary += '</td></tr>';
2860 });
2861 summary += '</table>';
2862 return summary;
2863 }
2864
2865 function batchEditSpecs(elements) {
2866 var keys = [
2867 { key: "fill", type: "color" },
2868 { key: "stroke", type: "color" },
2869 { key: "opacity", type: "number", min: "0", max: "1", step: "0.05" }
2870 ];
2871 if (elements.length > 0 && elements.every(isTextElement)) {
2872 keys = keys.concat([
2873 { key: "font-size", type: "text" },
2874 { key: "font-family", type: "text" },
2875 { key: "font-weight", type: "select",
2876 options: ["", "normal", "bold", "300", "400", "500", "600", "700", "800"] },
2877 { key: "text-anchor", type: "select", options: ["", "start", "middle", "end"] }
2878 ]);
2879 }
2880 return keys.map(function (spec) {
2881 var value = commonAttrValue(elements, spec.key);
2882 var copy = Object.assign({}, spec);
2883 copy.value = value === null ? "" : value;
2884 copy.mixed = value === null;
2885 return copy;
2886 });
2887 }
2888
2889 function isTextElement(el) {
2890 var tag = el.tagName.toLowerCase();
2891 return tag === "text" || tag === "tspan";
2892 }
2893
2894 function commonAttrValue(elements, key) {
2895 if (elements.length === 0) return "";
2896 var first = attrOrComputedValue(elements[0], key);
2897 for (var i = 1; i < elements.length; i++) {
2898 if (attrOrComputedValue(elements[i], key) !== first) return null;
2899 }
2900 return first;
2901 }
2902
2903 function attrOrComputedValue(el, key) {
2904 var value = el.getAttribute(key);
2905 if (value !== null && value !== "") return normalizeSvgLengthValue(key, value);
2906 var style = window.getComputedStyle(el);
2907 var map = {
2908 "fill": "fill",
2909 "stroke": "stroke",
2910 "opacity": "opacity",
2911 "font-size": "fontSize",
2912 "font-family": "fontFamily",
2913 "font-weight": "fontWeight",
2914 "text-anchor": "textAnchor"
2915 };
2916 return normalizeSvgLengthValue(key, (map[key] && style[map[key]]) || "");
2917 }
2918
2919 function renderBatchControl(spec) {
2920 var placeholder = spec.mixed ? t("multi_mixed") : "";
2921 if (spec.type === "color") {
2922 return '<input type="color" class="prop-edit prop-edit-batch-color" data-key="' +
2923 escapeHtml(spec.key) + '" value="' + normalizeHexForPicker(spec.value) + '">' +
2924 '<input type="text" class="prop-edit prop-edit-batch" data-key="' +
2925 escapeHtml(spec.key) + '" placeholder="' + escapeHtml(placeholder) +
2926 '" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
2927 }
2928 if (spec.type === "number") {
2929 return '<input type="number" class="prop-edit prop-edit-batch" data-key="' +
2930 escapeHtml(spec.key) + '" min="' + escapeHtml(spec.min || "") +
2931 '" max="' + escapeHtml(spec.max || "") + '" step="' +
2932 escapeHtml(spec.step || "1") + '" placeholder="' + escapeHtml(placeholder) +
2933 '" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
2934 }
2935 if (spec.type === "select") {
2936 var html = '<select class="prop-edit prop-edit-batch" data-key="' + escapeHtml(spec.key) + '">';
2937 spec.options.forEach(function (opt) {
2938 var label = opt || placeholder || "";
2939 var selected = !spec.mixed && spec.value === opt ? " selected" : "";
2940 html += '<option value="' + escapeHtml(opt) + '"' + selected + '>' +
2941 escapeHtml(label) + '</option>';
2942 });
2943 html += '</select>';
2944 return html;
2945 }
2946 return '<input type="text" class="prop-edit prop-edit-batch" data-key="' +
2947 escapeHtml(spec.key) + '" placeholder="' + escapeHtml(placeholder) +
2948 '" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
2949 }
2950
2951 function attachMultiSelectionEditors(ids) {
2952 var panel = elementPropsEl;
2953 var moveEls = topLevelSelectedElements(ids);
2954 if (moveEls.length === 0) return;
2955
2956 var readGeo = function (k) {
2957 var inp = panel.querySelector('.prop-edit-multi-geo[data-geo="' + k + '"]');
2958 return inp ? parseFloat(inp.value) : NaN;
2959 };
2960 var commitMove = function () {
2961 var rb;
2962 try { rb = computeElementsBox(moveEls); } catch (e) { return; }
2963 var nx = readGeo("x"), ny = readGeo("y");
2964 if (isNaN(nx) || isNaN(ny)) return;
2965 var dx = nx - rb.x, dy = ny - rb.y;
2966 if (Math.abs(dx) < 1e-6 && Math.abs(dy) < 1e-6) return;
2967
2968 var jobs = [];
2969 moveEls.forEach(function (el) {
2970 var attrs;
2971 try { attrs = attrsForMove(el, dx, dy); } catch (e) { attrs = null; }
2972 if (!attrs) return;
2973 var payload = payloadForMovedElement(el, attrs);
2974 jobs.push(stageEditRequest(el.id, payload).then(function () {
2975 if (payload.promote_tspan) promoteTspanDom(el, payload.promote_tspan);
2976 else {
2977 applyElementAttrs(el, attrs);
2978 markCommittedAttrs(el, attrs);
2979 }
2980 }));
2981 });
2982
2983 Promise.all(jobs)
2984 .then(function () { updateSelectionPanel(); })
2985 .catch(function (err) { showError(t("err_edit") + err.message); });
2986 };
2987
2988 panel.querySelectorAll(".prop-edit-multi-geo").forEach(function (inp) {
2989 inp.addEventListener("change", commitMove);
2990 });
2991
2992 panel.querySelectorAll(".prop-edit-batch-color").forEach(function (picker) {
2993 var key = picker.getAttribute("data-key");
2994 var text = panel.querySelector('.prop-edit-batch[data-key="' + key + '"]');
2995 picker.addEventListener("input", function () {
2996 if (text) text.value = picker.value;
2997 });
2998 picker.addEventListener("change", function () {
2999 if (text) text.value = picker.value;
3000 commitBatchAttr(key, picker.value, moveEls);
3001 });
3002 });
3003
3004 panel.querySelectorAll(".prop-edit-batch").forEach(function (input) {
3005 input.addEventListener("change", function () {
3006 var key = input.getAttribute("data-key");
3007 var value = normalizeEditableAttrValue(key, input.value);
3008 if (!value) return;
3009 commitBatchAttr(key, value, moveEls);
3010 });
3011 });
3012 }
3013
3014 function commitBatchAttr(key, value, elements) {
3015 value = normalizeEditableAttrValue(key, value);
3016 if ((key === "fill" || key === "stroke") && !isSafeColor(value)) {
3017 showError(t("err_edit") + key);
3018 return;
3019 }
3020 var jobs = elements.map(function (el) {
3021 var attrs = {};
3022 attrs[key] = value;
3023 return stageEditRequest(el.id, { attrs: attrs }).then(function () {
3024 applyElementAttrs(el, attrs);
3025 });
3026 });
3027 Promise.all(jobs)
3028 .then(function () { updateSelectionPanel(); })
3029 .catch(function (err) { showError(t("err_edit") + err.message); });
3030 }
3031
3032 // ================================================================
3033 // Boot
3034 // ================================================================
3035 applyI18n();
3036 initAnnotationQuickActions();
3037 updatePendingStatus();
3038 var langToggleBtn = document.getElementById("btn-lang-toggle");
3039 var langMenu = document.getElementById("lang-menu");
3040 if (langToggleBtn && langMenu) {
3041 refreshLangUI(LANG);
3042 var setMenuOpen = function (open) {
3043 langMenu.hidden = !open;
3044 langToggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
3045 if (open) {
3046 var sel = langMenu.querySelector("li.selected") || langMenu.querySelector("li[data-lang]");
3047 if (sel) sel.focus();
3048 }
3049 };
3050 var chooseLang = function (v) {
3051 setMenuOpen(false);
3052 langToggleBtn.focus();
3053 if (v) setLang(v);
3054 };
3055 langToggleBtn.addEventListener("click", function (e) {
3056 e.stopPropagation();
3057 setMenuOpen(langMenu.hidden);
3058 });
3059 langToggleBtn.addEventListener("keydown", function (e) {
3060 if (e.key === "Escape" && !langMenu.hidden) {
3061 e.stopPropagation();
3062 setMenuOpen(false);
3063 } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && langMenu.hidden) {
3064 e.preventDefault();
3065 e.stopPropagation();
3066 setMenuOpen(true);
3067 }
3068 });
3069 langMenu.addEventListener("click", function (e) {
3070 e.stopPropagation();
3071 var li = e.target && e.target.closest ? e.target.closest("li[data-lang]") : null;
3072 if (li) chooseLang(li.getAttribute("data-lang"));
3073 else setMenuOpen(false);
3074 });
3075 langMenu.addEventListener("keydown", function (e) {
3076 e.stopPropagation(); // keep nudge / slide-nav shortcuts away while the menu is open
3077 var items = Array.prototype.slice.call(langMenu.querySelectorAll("li[data-lang]"));
3078 var idx = items.indexOf(document.activeElement);
3079 if (e.key === "Escape") {
3080 setMenuOpen(false);
3081 langToggleBtn.focus();
3082 } else if (e.key === "ArrowDown") {
3083 e.preventDefault();
3084 (items[idx + 1] || items[0]).focus();
3085 } else if (e.key === "ArrowUp") {
3086 e.preventDefault();
3087 (items[idx - 1] || items[items.length - 1]).focus();
3088 } else if (e.key === "Home") {
3089 e.preventDefault();
3090 items[0].focus();
3091 } else if (e.key === "End") {
3092 e.preventDefault();
3093 items[items.length - 1].focus();
3094 } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
3095 e.preventDefault();
3096 } else if (e.key === "Enter" || e.key === " ") {
3097 e.preventDefault();
3098 if (idx >= 0) chooseLang(items[idx].getAttribute("data-lang"));
3099 }
3100 });
3101 langToggleBtn.parentElement.addEventListener("focusout", function (e) {
3102 if (!langMenu.hidden && !langToggleBtn.parentElement.contains(e.relatedTarget)) setMenuOpen(false);
3103 });
3104 document.addEventListener("click", function () {
3105 if (!langMenu.hidden) setMenuOpen(false);
3106 });
3107 }
3108
3109 loadConfig().then(function () {
3110 loadSlides();
3111 startSlidePolling();
3112 });
3113 initRubberBand();
3114 initKeyboardShortcuts();
3115 initSlideNav();
3116 if (btnUndo) btnUndo.addEventListener("click", runUndo);
3117 })();
3118
3118 lines JAVASCRIPT