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