/* ============================================================ PPT Master - SVG Editor | app.js Vanilla JS, IIFE pattern ============================================================ */ (function () { "use strict"; // ---- i18n ------------------------------------------------------- var MESSAGES = { en: { page_title: "PPT Master - Live Preview", panel_slides: "Slides", panel_annotations: "Annotations", panel_edit_annotate: "Edit / Annotate", placeholder_select_slide: "Select a slide on the left to begin", label_selected_element: "Selected element", empty_selected_element: "Click an element on the slide to select it", btn_select_group: "Select parent group", label_batch_edit: "Batch edit", label_group_edit: "Group edit", section_geometry: "Geometry", section_style: "Style", section_text_style: "Text", section_raw_attrs: "Raw attributes", label_edit_instruction: "Edit instruction", pending_none: "No pending changes", pending_summary: "{edits} direct edit(s), {annotations} annotation page(s) pending", pending_pages: "Pages: {pages}", quick_align: "Align / move", quick_resize: "Resize", quick_replace_image: "Replace image", quick_copy: "Revise copy", quick_relayout: "Relayout area", placeholder_annotation: "Describe how the AI should modify this element...", placeholder_annotation_multi: "Describe how to modify all {count} elements...", btn_add_annotation: "Add annotation", label_annotations_on_slide: "Annotations on this slide", btn_submit_annotations: "Apply changes", btn_exit_preview: "Exit preview", modal_submit: "Submit", modal_cancel: "Cancel", empty_waiting_slides: "Waiting for generated slides...", empty_no_slides: "No slides found", placeholder_live_ready: "Live preview is ready. Generated slides will appear here.", placeholder_slide_writing: "Slide is still being written. Waiting for the next refresh...", empty_annotations: "No annotations yet", tooltip_remove_annotation: "Remove annotation", multi_selected: "{count} elements selected", multi_mixed: "mixed", err_load_slides: "Failed to load slides: ", err_load_slide: "Failed to load slide: ", err_add_annotation: "Failed to add annotation: ", err_remove_annotation: "Failed to remove annotation: ", err_save: "Save failed: ", err_edit: "Edit failed: ", label_direct_edit: "Object attributes (pending until Apply changes)", prop_multiline_hint: "Multi-line — select a single line (tspan) to edit text", edit_saved_hint: "Change staged. Click Apply changes to write it to svg_output.", btn_undo: "Undo", undo_done: "Reverted last staged edit", undo_empty: "No staged edit to undo", overlap_caption: "Overlapping here — pick one", err_empty_svg: "Slide loaded but the canvas is empty. The SVG may be malformed or missing a root element.", warn_icon_inline: "{count} icon(s) failed to render: {names}", warn_matrix_transform: "This geometry edit is stored as a transform matrix. Preview is exact; PPTX export depends on matrix-aware conversion.", 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.", slide_error_tooltip: "Failed to parse this slide: ", reload_banner: "This slide was updated on disk. Click to reload.", 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.", modal_success_submit: "Changes saved to svg_output.\n\nThe preview service is still running.", 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.", 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.", 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.", modal_confirm_exit: "Exit preview and stop the local server?\n\nUnapplied edits and annotations will be discarded.", modal_success_exit: "Preview stopped.\n\nYou can close this tab and return to the chat.", modal_stopping: "Stopping preview server...", lang_toggle_title: "Switch language", nav_first: "First slide (Home)", nav_prev: "Previous slide (←)", nav_next: "Next slide (→)", nav_last: "Last slide (End)", nav_counter: "{current} / {total}", nav_empty: "— / —" }, ja: { page_title: "PPT Master - ライブプレビュー", panel_slides: "スライド", panel_annotations: "注釈", panel_edit_annotate: "編集 / 注釈", placeholder_select_slide: "左のスライドを選択して開始", label_selected_element: "選択中の要素", empty_selected_element: "スライド上の要素をクリックして選択", btn_select_group: "親グループを選択", label_batch_edit: "一括編集", label_group_edit: "グループ編集", section_geometry: "位置・サイズ", section_style: "スタイル", section_text_style: "テキスト", section_raw_attrs: "生の属性", label_edit_instruction: "編集指示", pending_none: "未適用の変更はありません", pending_summary: "直接編集{edits}件、AI注釈のあるページ{annotations}件が未適用", pending_pages: "対象ページ:{pages}", quick_align: "整列 / 移動", quick_resize: "サイズ変更", quick_replace_image: "画像を差し替え", quick_copy: "文言を修正", quick_relayout: "この領域を再レイアウト", placeholder_annotation: "この要素をAIにどう修正してほしいか記述…", placeholder_annotation_multi: "選択した{count}個の要素をどう修正するか記述…", btn_add_annotation: "注釈を追加", label_annotations_on_slide: "このスライドの注釈", btn_submit_annotations: "変更を適用", btn_exit_preview: "プレビューを終了", modal_submit: "送信", modal_cancel: "キャンセル", empty_waiting_slides: "スライドの生成を待っています…", empty_no_slides: "スライドが見つかりません", placeholder_live_ready: "ライブプレビュー準備完了。生成されたスライドがここに表示されます。", placeholder_slide_writing: "スライドはまだ書き込み中です。次の更新を待っています…", empty_annotations: "注釈はまだありません", tooltip_remove_annotation: "注釈を削除", multi_selected: "{count}個の要素を選択中", multi_mixed: "混在", err_load_slides: "スライド一覧の読み込みに失敗: ", err_load_slide: "スライドの読み込みに失敗: ", err_add_annotation: "注釈の追加に失敗: ", err_remove_annotation: "注釈の削除に失敗: ", err_save: "保存に失敗: ", err_edit: "編集に失敗: ", label_direct_edit: "オブジェクト属性(「変更を適用」までは保留)", prop_multiline_hint: "複数行テキストです — 文字を編集するには1行(tspan)を選択してください", edit_saved_hint: "変更を一時保存しました。「変更を適用」で svg_output に書き込まれます。", btn_undo: "元に戻す", undo_done: "直前の一時保存済み編集を取り消しました", undo_empty: "取り消せる編集はありません", overlap_caption: "要素が重なっています — 1つ選択してください", err_empty_svg: "スライドは読み込めましたがキャンバスが空です。SVGが不正か、ルート要素がない可能性があります。", warn_icon_inline: "{count}個のアイコンを描画できませんでした: {names}", warn_matrix_transform: "このジオメトリ編集は transform matrix として保存されます。プレビューは正確ですが、PPTX出力にはmatrix対応の現行エクスポーターが必要です。", modal_matrix_transform_note: "\n\n注意:一時保存済みのジオメトリ編集に transform matrix を使うものがあります。matrixが反映されるよう、現行のPPTXエクスポーターで再エクスポートしてください。", slide_error_tooltip: "このスライドの解析に失敗: ", reload_banner: "このスライドはディスク上で更新されました。クリックで再読み込み。", modal_confirm_submit: "一時保存済みの直接編集とAI注釈をディスクに書き込みますか?\n\nプレビューサービスは動き続けます。止めたいときは「プレビューを終了」を押してください。", modal_success_submit: "変更を svg_output に保存しました。\n\nプレビューサービスは引き続き動作中です。", modal_success_direct_only: "変更を svg_output に保存しました。\n\n直接編集はSVGソースに反映済みです。PPTXを更新したいときは、チャットに戻って再エクスポートを依頼してください。プレビューサービスは引き続き動作中です。", modal_success_annotations_only: "注釈を svg_output に保存しました。\n\nAIに注釈を解釈・反映させたいときは、チャットに戻って注釈の適用を依頼してください。プレビューサービスは引き続き動作中です。", modal_success_mixed: "直接編集と注釈を svg_output に保存しました。\n\nチャットに戻ってAI判断が必要な注釈の適用を先に依頼し、確認できたらPPTXを再エクスポートしてください。プレビューサービスは引き続き動作中です。", modal_confirm_exit: "プレビューを終了してローカルサーバーを停止しますか?\n\n未適用の編集と注釈は破棄されます。", modal_success_exit: "プレビューを停止しました。\n\nこのタブを閉じてチャットに戻れます。", modal_stopping: "プレビューサーバーを停止しています…", lang_toggle_title: "言語を切り替え", nav_first: "最初のスライド (Home)", nav_prev: "前のスライド (←)", nav_next: "次のスライド (→)", nav_last: "最後のスライド (End)", nav_counter: "{current} / {total}", nav_empty: "— / —" }, zh: { page_title: "PPT Master - 实时预览", panel_slides: "幻灯片", panel_annotations: "标注", panel_edit_annotate: "编辑 / 标注", placeholder_select_slide: "在左侧选择一张幻灯片开始", label_selected_element: "已选元素", empty_selected_element: "点击幻灯片中的元素进行选择", btn_select_group: "选择父级组", label_batch_edit: "批量编辑", label_group_edit: "组编辑", section_geometry: "几何", section_style: "样式", section_text_style: "文本", section_raw_attrs: "原始属性", label_edit_instruction: "修改说明", pending_none: "没有待应用修改", pending_summary: "{edits} 条直接修改、{annotations} 个页面有 AI 标注待应用", pending_pages: "页面:{pages}", quick_align: "对齐/移动", quick_resize: "调整大小", quick_replace_image: "换图", quick_copy: "改文案", quick_relayout: "重排此区域", placeholder_annotation: "描述希望 AI 如何修改该元素……", placeholder_annotation_multi: "描述希望如何修改所选 {count} 个元素……", btn_add_annotation: "添加标注", label_annotations_on_slide: "本页标注", btn_submit_annotations: "应用修改", btn_exit_preview: "退出预览", modal_submit: "提交", modal_cancel: "取消", empty_waiting_slides: "正在等待生成幻灯片……", empty_no_slides: "未找到幻灯片", placeholder_live_ready: "实时预览已就绪,生成的幻灯片会在这里出现。", placeholder_slide_writing: "幻灯片仍在写入,等待下次刷新……", empty_annotations: "暂无标注", tooltip_remove_annotation: "删除标注", multi_selected: "已选 {count} 个元素", multi_mixed: "混合", err_load_slides: "加载幻灯片失败:", err_load_slide: "加载幻灯片失败:", err_add_annotation: "添加标注失败:", err_remove_annotation: "删除标注失败:", err_save: "保存失败:", err_edit: "编辑失败:", label_direct_edit: "对象属性(点击应用修改后写入)", prop_multiline_hint: "多行文本——选中单行(tspan)编辑文字", edit_saved_hint: "修改已暂存。点击“应用修改”后写入 svg_output。", btn_undo: "撤销", undo_done: "已撤销上一条暂存修改", undo_empty: "没有可撤销的暂存修改", overlap_caption: "此处重叠元素——点击选择", err_empty_svg: "幻灯片已加载但画布为空。SVG 可能损坏或缺少根 元素。", warn_icon_inline: "{count} 个图标渲染失败:{names}", warn_matrix_transform: "本次几何修改会以 transform matrix 保存。预览是准确的;PPTX 导出需要使用支持 matrix 的当前导出器。", modal_matrix_transform_note: "\n\n提示:至少有一条暂存几何修改使用了 transform matrix。请用当前 PPTX 导出器重新导出,确保 matrix 被应用。", slide_error_tooltip: "该幻灯片解析失败:", reload_banner: "当前页已在磁盘上更新,点此重新加载。", modal_confirm_submit: "确认将暂存的直接修改和 AI 标注写入磁盘?\n\n预览服务会继续运行。需要关闭时请点击退出预览。", modal_success_submit: "修改已保存到 svg_output。\n\n预览服务仍在运行。", modal_success_direct_only: "修改已保存到 svg_output。\n\n直接修改已经写入 SVG 源文件;需要刷新 PPTX 时,请回到对话窗口要求重新导出。预览服务仍在运行。", modal_success_annotations_only: "标注已保存到 svg_output。\n\n需要 AI 理解并执行这些标注时,请回到对话窗口要求应用标注。预览服务仍在运行。", modal_success_mixed: "直接修改和标注已保存到 svg_output。\n\n请回到对话窗口先应用需要 AI 判断的标注,确认后再重新导出 PPTX。预览服务仍在运行。", modal_confirm_exit: "退出预览并停止本地服务?\n\n未应用的属性修改和标注将被丢弃。", modal_success_exit: "预览已停止。\n\n可以关闭本标签页并回到对话窗口。", modal_stopping: "正在停止预览服务……", lang_toggle_title: "切换语言", nav_first: "第一页 (Home)", nav_prev: "上一页 (←)", nav_next: "下一页 (→)", nav_last: "末页 (End)", nav_counter: "{current} / {total}", nav_empty: "— / —" }, "zh-TW": { page_title: "PPT Master - 即時預覽", panel_slides: "投影片", panel_annotations: "標註", panel_edit_annotate: "編輯 / 標註", placeholder_select_slide: "在左側選擇一張投影片開始", label_selected_element: "已選元素", empty_selected_element: "點選投影片中的元素進行選擇", btn_select_group: "選擇父群組", label_batch_edit: "批次編輯", label_group_edit: "群組編輯", section_geometry: "幾何", section_style: "樣式", section_text_style: "文字", section_raw_attrs: "原始屬性", label_edit_instruction: "修改說明", pending_none: "沒有待套用修改", pending_summary: "{edits} 筆直接修改、{annotations} 個頁面有 AI 標註待套用", pending_pages: "頁面:{pages}", quick_align: "對齊/移動", quick_resize: "調整大小", quick_replace_image: "換圖", quick_copy: "改文案", quick_relayout: "重排此區域", placeholder_annotation: "描述希望 AI 如何修改該元素……", placeholder_annotation_multi: "描述希望如何修改所選 {count} 個元素……", btn_add_annotation: "新增標註", label_annotations_on_slide: "本頁標註", btn_submit_annotations: "套用修改", btn_exit_preview: "結束預覽", modal_submit: "提交", modal_cancel: "取消", empty_waiting_slides: "正在等待生成投影片……", empty_no_slides: "未找到投影片", placeholder_live_ready: "即時預覽已就緒,生成的投影片會在這裡出現。", placeholder_slide_writing: "投影片仍在寫入,等待下次重新整理……", empty_annotations: "尚無標註", tooltip_remove_annotation: "刪除標註", multi_selected: "已選 {count} 個元素", multi_mixed: "混合", err_load_slides: "載入投影片失敗:", err_load_slide: "載入投影片失敗:", err_add_annotation: "新增標註失敗:", err_remove_annotation: "刪除標註失敗:", err_save: "儲存失敗:", err_edit: "編輯失敗:", label_direct_edit: "物件屬性(點選套用修改後寫入)", prop_multiline_hint: "多行文字——選取單行(tspan)編輯文字", edit_saved_hint: "修改已暫存。點選「套用修改」後寫入 svg_output。", btn_undo: "復原", undo_done: "已復原上一筆暫存修改", undo_empty: "沒有可復原的暫存修改", overlap_caption: "此處元素重疊——請點選其中一個", err_empty_svg: "投影片已載入但畫布為空。SVG 可能損壞或缺少根 元素。", warn_icon_inline: "{count} 個圖示渲染失敗:{names}", warn_matrix_transform: "本次幾何修改會以 transform matrix 儲存。預覽是準確的;PPTX 匯出需要使用支援 matrix 的目前匯出器。", modal_matrix_transform_note: "\n\n提示:至少有一筆暫存幾何修改使用了 transform matrix。請用目前 PPTX 匯出器重新匯出,確保 matrix 被套用。", slide_error_tooltip: "該投影片解析失敗:", reload_banner: "目前頁已在磁碟上更新,點此重新載入。", modal_confirm_submit: "確認將暫存的直接修改和 AI 標註寫入磁碟?\n\n預覽服務會繼續執行。需要關閉時請點選結束預覽。", modal_success_submit: "修改已儲存到 svg_output。\n\n預覽服務仍在執行。", modal_success_direct_only: "修改已儲存到 svg_output。\n\n直接修改已經寫入 SVG 原始檔;需要重新整理 PPTX 時,請回到對話視窗要求重新匯出。預覽服務仍在執行。", modal_success_annotations_only: "標註已儲存到 svg_output。\n\n需要 AI 理解並執行這些標註時,請回到對話視窗要求套用標註。預覽服務仍在執行。", modal_success_mixed: "直接修改和標註已儲存到 svg_output。\n\n請回到對話視窗先套用需要 AI 判斷的標註,確認後再重新匯出 PPTX。預覽服務仍在執行。", modal_confirm_exit: "結束預覽並停止本機服務?\n\n未套用的屬性修改和標註將被丟棄。", modal_success_exit: "預覽已停止。\n\n可以關閉本分頁並回到對話視窗。", modal_stopping: "正在停止預覽服務……", lang_toggle_title: "切換語言", nav_first: "第一頁 (Home)", nav_prev: "上一頁 (←)", nav_next: "下一頁 (→)", nav_last: "末頁 (End)", nav_counter: "{current} / {total}", nav_empty: "— / —" } }; var LANG = (function () { try { var stored = window.localStorage.getItem("ppt_lang"); if (stored === "zh" || stored === "en" || stored === "ja" || stored === "zh-TW") return stored; } catch (e) { /* ignore */ } var nav = (navigator.language || navigator.userLanguage || "en").toLowerCase(); if (nav.indexOf("zh") === 0) { if (/\bhans\b/.test(nav)) return "zh"; if (/\bhant\b/.test(nav) || /\b(tw|hk|mo)\b/.test(nav)) return "zh-TW"; return "zh"; } if (nav.indexOf("ja") === 0) return "ja"; return "en"; })(); function t(key, params) { var dict = MESSAGES[LANG] || MESSAGES.en; var msg = dict[key]; if (msg === undefined) msg = MESSAGES.en[key]; if (msg === undefined) return key; if (params) { Object.keys(params).forEach(function (p) { msg = msg.replace("{" + p + "}", params[p]); }); } return msg; } function applyI18n() { document.documentElement.setAttribute("lang", LANG === "zh" ? "zh-CN" : (LANG === "zh-TW" ? "zh-TW" : (LANG === "ja" ? "ja" : "en"))); document.title = t("page_title"); document.querySelectorAll("[data-i18n]").forEach(function (el) { el.textContent = t(el.getAttribute("data-i18n")); }); document.querySelectorAll("[data-i18n-placeholder]").forEach(function (el) { el.placeholder = t(el.getAttribute("data-i18n-placeholder")); }); document.querySelectorAll("[data-i18n-title]").forEach(function (el) { el.title = t(el.getAttribute("data-i18n-title")); }); updateNavLabel(); } var LANG_NAMES = { zh: "中文", en: "English", ja: "日本語", "zh-TW": "繁體中文" }; function refreshLangUI(lang) { // Custom dropdown (OS-independent): button shows the CURRENT language. var cur = document.getElementById("lang-current"); if (cur) cur.textContent = LANG_NAMES[lang] || lang; var btn = document.getElementById("btn-lang-toggle"); if (btn) btn.title = t("lang_toggle_title"); document.querySelectorAll("#lang-menu li").forEach(function (li) { var selected = li.getAttribute("data-lang") === lang; li.classList.toggle("selected", selected); li.setAttribute("aria-selected", selected ? "true" : "false"); }); } function setLang(lang) { if (lang !== "zh" && lang !== "en" && lang !== "ja" && lang !== "zh-TW") return; LANG = lang; try { window.localStorage.setItem("ppt_lang", lang); } catch (e) { /* ignore */ } applyI18n(); refreshLangUI(lang); // Re-render dynamic regions so they pick up the new language updateSelectionPanel(); updateAnnotationList(); updateUndoButton(); updatePendingStatus(); initAnnotationQuickActions(); loadSlides(); } // ---- DOM refs --------------------------------------------------- var slideListEl = document.getElementById("slide-list"); var svgPlaceholder = document.getElementById("svg-placeholder"); var svgContent = document.getElementById("svg-content"); var selectedElementEl = document.getElementById("selected-element"); var annotationInput = document.getElementById("annotation-input"); var annotationQuickActions = document.getElementById("annotation-quick-actions"); var annotationText = document.getElementById("annotation-text"); var btnAddAnnotation = document.getElementById("btn-add-annotation"); var annotationsEl = document.getElementById("annotations"); var btnUndo = document.getElementById("btn-undo"); var btnSave = document.getElementById("btn-save"); var btnExitPreview = document.getElementById("btn-exit-preview"); var modalOverlay = document.getElementById("modal-overlay"); var modalMessage = document.getElementById("modal-message"); var modalConfirm = document.getElementById("modal-confirm"); var modalCancel = document.getElementById("modal-cancel"); var elementPropsEl = document.getElementById("element-props"); var pendingStatusEl = document.getElementById("pending-status"); var navFirstBtn = document.getElementById("nav-first"); var navPrevBtn = document.getElementById("nav-prev"); var navNextBtn = document.getElementById("nav-next"); var navLastBtn = document.getElementById("nav-last"); var navCounterEl = document.getElementById("nav-counter"); var navNameEl = document.getElementById("nav-name"); // ---- State ------------------------------------------------------ var currentSlide = null; // filename, e.g. "slide_01.svg" var waitingForSlide = null; // current slide is temporarily unreadable in live mode var slideNames = []; // ordered slide filenames for navigation var selectedElementIds = new Set(); // id attrs of selected SVG elements var slideAnnotations = {}; // {element_id: annotation_text} for current slide var liveMode = false; var slidePollTimer = null; var pendingModalAction = "submit"; var slideMtimes = {}; // {name: mtime} — last-seen mtime for each slide var reloadBannerEl = null; // singleton banner element shown when currentSlide mtime drifts var editStackCount = {}; // {name: staged edit count} — mirrors backend PENDING_EDITS var savedHintShown = false; // show the "staged edit" hint once per session var matrixHintShown = false; // show transform-matrix export hint once per session var matrixEditSlides = {}; // {name: true} when a staged edit wrote transform=matrix(...) var annotationsDirty = false; // unsaved annotations added/removed this session var annotationEditSlides = {}; // {name: true} when annotations changed this session function pendingDirectEditCount() { return Object.keys(editStackCount).reduce(function (total, name) { return total + Math.max(0, editStackCount[name] || 0); }, 0); } function pendingDirectEditSlides() { return Object.keys(editStackCount).filter(function (name) { return (editStackCount[name] || 0) > 0; }); } function pendingAnnotationSlides() { return Object.keys(annotationEditSlides).filter(function (name) { return !!annotationEditSlides[name]; }); } function pendingAnnotationChangeCount() { return pendingAnnotationSlides().length; } function pendingSlideNames() { var seen = {}; pendingDirectEditSlides().concat(pendingAnnotationSlides()).forEach(function (name) { seen[name] = true; }); return Object.keys(seen).sort(); } function updatePendingStatus() { if (!pendingStatusEl) return; var edits = pendingDirectEditCount(); var annotations = pendingAnnotationChangeCount(); if (edits === 0 && annotations === 0) { pendingStatusEl.className = "pending-status empty"; pendingStatusEl.textContent = t("pending_none"); return; } var pages = pendingSlideNames(); pendingStatusEl.className = "pending-status active"; pendingStatusEl.innerHTML = '
' + escapeHtml(t("pending_summary", { edits: edits, annotations: annotations, })) + '
' + (pages.length ? '
' + escapeHtml(t("pending_pages", { pages: pages.join(", "), })) + '
' : ''); } function markAnnotationChanged(slide) { if (!slide) return; annotationEditSlides[slide] = true; annotationsDirty = true; updatePendingStatus(); } function initAnnotationQuickActions() { if (!annotationQuickActions) return; var actions = [ { key: "quick_align" }, { key: "quick_resize" }, { key: "quick_replace_image" }, { key: "quick_copy" }, { key: "quick_relayout" }, ]; annotationQuickActions.innerHTML = ""; actions.forEach(function (action) { var btn = document.createElement("button"); btn.type = "button"; btn.className = "annotation-quick-chip"; btn.textContent = t(action.key); btn.addEventListener("click", function () { var label = t(action.key); var prefix = (LANG === "zh" || LANG === "ja") ? label + ":" : label + ": "; if (!annotationText.value.trim()) { annotationText.value = prefix; } else if (annotationText.value.indexOf(prefix) === -1) { annotationText.value = prefix + annotationText.value; } annotationText.focus(); annotationText.selectionStart = annotationText.selectionEnd = annotationText.value.length; }); annotationQuickActions.appendChild(btn); }); } // Staged edits live in server memory until "Apply changes"; the server can // still idle-timeout or be killed and drop them. Warn before the tab leaves // so the user remembers to apply (browsers show their own generic prompt). function hasUnsavedWork() { if (annotationsDirty) return true; return Object.keys(editStackCount).some(function (k) { return editStackCount[k] > 0; }); } window.addEventListener("beforeunload", function (e) { if (!hasUnsavedWork()) return undefined; e.preventDefault(); e.returnValue = ""; return ""; }); function readSlideFromLocation() { try { var params = new URLSearchParams(window.location.hash.replace(/^#/, "")); return params.get("slide"); } catch (err) { return null; } } function rememberSlideInLocation(name) { if (!name) return; var params; try { params = new URLSearchParams(window.location.hash.replace(/^#/, "")); } catch (err) { params = new URLSearchParams(); } params.set("slide", name); var url = window.location.pathname + window.location.search + "#" + params.toString(); window.history.replaceState(null, "", url); } function currentSlideIndex() { if (!currentSlide) return -1; return slideNames.indexOf(currentSlide); } function gotoSlideIndex(idx) { if (idx < 0 || idx >= slideNames.length) return; var name = slideNames[idx]; if (name === currentSlide) return; var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(name) + '"]'); selectSlide(name, item || undefined); } function cssAttr(value) { return String(value).replace(/"/g, '\\"'); } function updateNavLabel() { if (!navCounterEl) return; var total = slideNames.length; if (total === 0 || !currentSlide) { navCounterEl.textContent = t("nav_empty"); if (navNameEl) navNameEl.textContent = ""; } else { var idx = currentSlideIndex(); navCounterEl.textContent = t("nav_counter", { current: idx + 1, total: total }); if (navNameEl) navNameEl.textContent = currentSlide; } var idx2 = currentSlideIndex(); var hasCurrent = idx2 >= 0; if (navFirstBtn) navFirstBtn.disabled = !hasCurrent || idx2 === 0; if (navPrevBtn) navPrevBtn.disabled = !hasCurrent || idx2 <= 0; if (navNextBtn) navNextBtn.disabled = !hasCurrent || idx2 >= total - 1; if (navLastBtn) navLastBtn.disabled = !hasCurrent || idx2 >= total - 1; } // ================================================================ // 1. loadSlides -- GET /api/slides // ================================================================ function loadSlides() { return fetch("/api/slides") .then(function (res) { return res.json(); }) .then(function (data) { slideListEl.innerHTML = ""; var slides = data.slides || []; slideNames = slides.map(function (s) { return s.name; }); if (slides.length === 0) { var empty = document.createElement("div"); empty.className = "slide-list-empty"; empty.textContent = liveMode ? t("empty_waiting_slides") : t("empty_no_slides"); slideListEl.appendChild(empty); if (!currentSlide) { svgPlaceholder.style.display = "block"; svgPlaceholder.textContent = liveMode ? t("placeholder_live_ready") : t("empty_no_slides"); svgContent.style.display = "none"; } updateNavLabel(); updatePendingStatus(); return; } var currentExists = false; var currentReady = false; var currentMtimeChanged = false; slides.forEach(function (s) { if (s.name === currentSlide) { currentExists = true; currentReady = s.ok !== false; // Compare against the mtime we recorded when we last rendered this slide. var lastSeen = slideMtimes[s.name]; if (lastSeen !== undefined && s.mtime && s.mtime !== lastSeen) { currentMtimeChanged = true; } } // Track every slide's mtime for the next poll (only update non-current here; // currentSlide's mtime is updated by selectSlide so we can detect drift). if (s.name !== currentSlide && s.mtime !== undefined) { slideMtimes[s.name] = s.mtime; } var item = document.createElement("div"); item.className = "slide-item" + (s.name === currentSlide ? " active" : ""); if (s.ok === false) { item.className += " slide-error"; item.title = t("slide_error_tooltip") + (s.error || ""); } item.setAttribute("data-name", s.name); var nameSpan = document.createElement("span"); nameSpan.className = "slide-name"; nameSpan.textContent = s.name; item.appendChild(nameSpan); if (s.annotation_count > 0) { var badge = document.createElement("span"); badge.className = "badge"; badge.textContent = s.annotation_count; item.appendChild(badge); } item.addEventListener("click", function () { selectSlide(s.name, item); }); slideListEl.appendChild(item); }); if (!currentSlide || !currentExists) { var rememberedSlide = readSlideFromLocation(); var targetSlide = rememberedSlide && slideNames.indexOf(rememberedSlide) !== -1 ? rememberedSlide : slides[0].name; selectSlide(targetSlide); } else if (waitingForSlide === currentSlide) { if (currentReady) { selectSlide(currentSlide); } } else if (currentMtimeChanged) { showReloadBanner(currentSlide); } updateNavLabel(); updatePendingStatus(); }) .catch(function (err) { console.error("loadSlides:", err); showError(t("err_load_slides") + err.message); }); } // ================================================================ // 2. selectSlide -- GET /api/slide/{name} // ================================================================ function waitForSlideRewrite(name) { if (!liveMode || name !== currentSlide) return; waitingForSlide = name; svgPlaceholder.style.display = "block"; svgPlaceholder.textContent = t("placeholder_slide_writing"); svgContent.style.display = "none"; } function selectSlide(name, el) { if (!el) { el = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(name) + '"]'); } // Update active class in sidebar document.querySelectorAll(".slide-item").forEach(function (it) { it.classList.remove("active"); }); if (el) el.classList.add("active"); currentSlide = name; waitingForSlide = null; rememberSlideInLocation(name); selectedElementIds.clear(); slideAnnotations = {}; updateNavLabel(); // Reset right panel and rubber band cancelRubberBand(); clearSelection(); // Selecting a slide implicitly dismisses any stale "page updated" banner. hideReloadBanner(); fetch("/api/slide/" + encodeURIComponent(name)) .then(function (res) { return res.json(); }) .then(function (data) { if (name !== currentSlide) return; if (data.error) { console.error("selectSlide:", data.error); showError(t("err_load_slide") + data.error); waitForSlideRewrite(name); return; } // Render SVG svgPlaceholder.style.display = "none"; svgContent.style.display = "block"; svgContent.innerHTML = sanitizeSvg(data.content); // Empty-canvas guard: surface a clear error if the SVG parsed // to nothing renderable (issue #115's silent-blank scenario). var rootSvg = svgContent.querySelector("svg"); // viewBox is the PPT Master canvas authority. Normalize the // preview DOM from it so stale or missing root width/height // cannot shrink the slide. View-layer only — the file on disk // is never touched. if (rootSvg) { var vb = (rootSvg.getAttribute("viewBox") || "").trim().split(/[\s,]+/); var vbWidth = Number(vb[2]); var vbHeight = Number(vb[3]); if ( vb.length === 4 && Number.isFinite(vbWidth) && Number.isFinite(vbHeight) && vbWidth > 0 && vbHeight > 0 ) { rootSvg.setAttribute("width", vb[2]); rootSvg.setAttribute("height", vb[3]); } } var hasContent = false; if (rootSvg) { var children = rootSvg.querySelectorAll("*"); for (var i = 0; i < children.length; i++) { var ctag = children[i].tagName.toLowerCase(); if (ctag !== "defs" && ctag !== "style" && ctag !== "title" && ctag !== "desc") { hasContent = true; break; } } } if (!rootSvg || !hasContent) { showError(t("err_empty_svg")); svgPlaceholder.style.display = "block"; svgPlaceholder.textContent = t("err_empty_svg"); svgContent.style.display = "none"; return; } // Non-fatal warnings (e.g. icon inline failures): surface as a // single combined toast so users know why something looks off. if (data.warnings && data.warnings.length > 0) { var names = data.warnings.map(function (w) { return w.icon || "(unknown)"; }).join(", "); showWarning(t("warn_icon_inline", { count: data.warnings.length, names: names, })); } // Record the mtime so the next poll can detect on-disk drift. if (data.mtime !== undefined) { slideMtimes[name] = data.mtime; } // Build annotations map from response (data.annotations || []).forEach(function (a) { slideAnnotations[a.element_id] = a.annotation; }); editStackCount[name] = data.undo_depth || 0; setupSvgInteraction(); refreshAnnotationVisuals(); updateAnnotationList(); updateUndoButton(); updatePendingStatus(); }) .catch(function (err) { if (name !== currentSlide) return; console.error("selectSlide:", err); showError(t("err_load_slide") + err.message); waitForSlideRewrite(name); }); } // ================================================================ // 3. setupSvgInteraction // ================================================================ var SKIP_TAGS = ["defs", "style", "title", "desc"]; function setupSvgInteraction() { var svg = svgContent.querySelector("svg"); if (!svg) return; // Visual class only — selectability is handled by the delegated handler below. // Skipping the per-element addEventListener brings listener-registration time // from O(n) to O(1), which matters on decks with hundreds of elements per slide. var allEls = svg.querySelectorAll("*"); allEls.forEach(function (el) { var tag = el.tagName.toLowerCase(); if (SKIP_TAGS.indexOf(tag) !== -1) return; if (el === svg) return; el.classList.add("svg-selectable"); }); svg.addEventListener("click", function (e) { // Skip the synthetic click that follows a rubber-band drag-release. if (suppressNextSvgClick) { suppressNextSvgClick = false; return; } var target = e.target; // Blank-area click on the svg root → clear selection. if (target === svg) { clearSelection(); return; } // Ignore clicks bubbling out of non-interactive subtrees. if (target.closest && target.closest("defs, style, title, desc")) return; // Backend assign_temp_ids() guarantees every element has an id, so // closest("[id]") will always find a hit. The exclusion of `svg` // itself routes "click outside any real shape" to clearSelection. // Icon placeholders are expanded only for browser preview. If a // click lands on a generated child, edit the disk-backed data-icon // group instead of an inner preview-only node. var picked = e.altKey && target.closest ? target.closest("g[id]") : null; picked = picked || (target.closest && target.closest("[data-icon][id]")) || target.closest("[id]"); if (!picked || picked === svg) { clearSelection(); return; } selectElement(picked, e.ctrlKey || e.metaKey); }); } // ================================================================ // 4. selectElement // ================================================================ function selectElement(elem, addToSelection) { var eid = elem.id; if (!eid) return; if (addToSelection) { // Ctrl+click: toggle this element if (selectedElementIds.has(eid)) { selectedElementIds.delete(eid); elem.classList.remove("svg-selected"); } else { selectedElementIds.add(eid); elem.classList.add("svg-selected"); } } else { // Normal click: clear others, select only this one selectedElementIds.forEach(function (id) { if (id !== eid) { var old = svgContent.querySelector("#" + CSS.escape(id)); if (old) old.classList.remove("svg-selected"); } }); selectedElementIds.clear(); selectedElementIds.add(eid); elem.classList.add("svg-selected"); } updateSelectionPanel(); } // ================================================================ // 5. clearSelection // ================================================================ function clearSelection() { selectedElementIds.forEach(function (id) { var el = svgContent.querySelector("#" + CSS.escape(id)); if (el) el.classList.remove("svg-selected"); }); selectedElementIds.clear(); updateSelectionPanel(); } function updateSelectionPanel() { var propsEl = elementPropsEl; var count = selectedElementIds.size; if (count === 0) { selectedElementEl.classList.add("empty"); selectedElementEl.textContent = t("empty_selected_element"); annotationInput.style.display = "none"; annotationText.value = ""; propsEl.style.display = "none"; propsEl.innerHTML = ""; return; } selectedElementEl.classList.remove("empty"); propsEl.style.display = "block"; if (count === 1) { var eid = selectedElementIds.values().next().value; var el = svgContent.querySelector("#" + CSS.escape(eid)); if (el) { var tag = el.tagName.toLowerCase(); selectedElementEl.innerHTML = '<' + escapeHtml(tag) + '>' + '' + escapeHtml(eid) + ''; propsEl.innerHTML = renderEditableProps(el); attachPropEditors(el); } } else { selectedElementEl.innerHTML = '' + escapeHtml(t("multi_selected", { count: count })) + ''; propsEl.innerHTML = renderMultiSelectSummary(Array.from(selectedElementIds)); attachMultiSelectionEditors(Array.from(selectedElementIds)); } annotationInput.style.display = "block"; annotationText.placeholder = count > 1 ? t("placeholder_annotation_multi", { count: count }) : t("placeholder_annotation"); annotationText.value = count === 1 ? (slideAnnotations[selectedElementIds.values().next().value] || "") : ""; } // ---- Rubber band selection ---- var rubberBandEl = null; var rubberBandStart = null; var rubberBandUsed = false; var suppressNextSvgClick = false; var RUBBER_BAND_THRESHOLD = 5; // ---- Drag-to-move (direct geometry edit on the canvas) ---- // Pressing on an already-selected element drags the whole selection. Frames // only update the preview; one staged edit per element is written on release, // reusing attrsForMove + stageEditRequest (and backend coalescing). var dragStart = null; // {x, y} screen coords at mousedown on a selection var dragMoved = false; // crossed the move threshold this gesture var dragEls = []; // top-level selected elements being moved var dragStartAttrs = {}; // {id: startGeometryAttrs} for drift-free preview function hitSelectedAncestor(target) { // Walk up from the pressed node to the first element already in the // selection, so a press on a selected shape (or its inner preview // children) starts a drag regardless of how it was selected. var svg = svgContent.querySelector("svg"); var node = target; while (node && node !== svg && node.nodeType === 1) { if (node.id && selectedElementIds.has(node.id)) return node; node = node.parentNode; } return null; } function screenDeltaToLocal(el, dxScreen, dyScreen) { // Map a screen-pixel delta into the coordinate space computeRenderBox / // attrsForMove use for THIS element (its getCTM() output space). Using the // element's own getCTM ∘ getScreenCTM⁻¹ is robust to how a browser splits // the viewBox scale between getCTM and getScreenCTM, and naturally accounts // for each element's own group transform (correct under multi-select). var sctm = el.getScreenCTM ? el.getScreenCTM() : null; var ctm = el.getCTM ? el.getCTM() : null; if (!sctm || !ctm) return { x: dxScreen, y: dyScreen }; try { var n = ctm.multiply(sctm.inverse()); // linear part: screen → local delta var lx = n.a * dxScreen + n.c * dyScreen; var ly = n.b * dxScreen + n.d * dyScreen; // Singular CTM → SVGMatrix.inverse() throws, DOMMatrix yields NaN. // Either way, fall back to the raw delta rather than wedge the drag. if (!isFinite(lx) || !isFinite(ly)) return { x: dxScreen, y: dyScreen }; return { x: lx, y: ly }; } catch (err) { return { x: dxScreen, y: dyScreen }; } } function beginDrag() { dragEls = topLevelSelectedElements(Array.from(selectedElementIds)); dragStartAttrs = {}; dragEls.forEach(function (el) { // attrsForMove(el, 0, 0) returns the element's current geometry in the // exact attribute shape a move writes (x/y, cx/cy, … or transform); // restoring it before each frame keeps the preview drift-free. try { dragStartAttrs[el.id] = attrsForMove(el, 0, 0); } catch (err) { dragStartAttrs[el.id] = null; } }); document.body.classList.add("svg-dragging"); } function applyAttrSnapshot(el, sa) { if (!sa) return; applyElementAttrs(el, sa); } var INLINE_GEOMETRY_STYLE_PROPERTIES = { rect: new Set(["x", "y", "width", "height", "rx", "ry"]), circle: new Set(["cx", "cy", "r"]), ellipse: new Set(["cx", "cy", "rx", "ry"]), image: new Set(["x", "y", "width", "height"]), svg: new Set(["x", "y", "width", "height"]), use: new Set(["x", "y", "width", "height"]) }; function stripEditedInlineGeometry(el, attrs) { var style = el.getAttribute("style"); var supported = INLINE_GEOMETRY_STYLE_PROPERTIES[localName(el)]; if (!style || !supported) return; var edited = new Set(Object.keys(attrs || {}).map(function (key) { return String(key).toLowerCase(); }).filter(function (key) { return supported.has(key); })); if (edited.size === 0) return; var retained = []; var changed = false; style.split(";").forEach(function (rawDeclaration) { var declaration = rawDeclaration.trim(); if (!declaration) return; var colon = declaration.indexOf(":"); if (colon < 0) { retained.push(declaration); return; } var name = declaration.slice(0, colon).trim().toLowerCase(); if (edited.has(name)) { changed = true; return; } retained.push(declaration); }); if (!changed) return; if (retained.length > 0) el.setAttribute("style", retained.join("; ")); else el.removeAttribute("style"); } function applyElementAttrs(el, attrs) { stripEditedInlineGeometry(el, attrs); if (localName(el) === "g" && el.hasAttribute("data-icon") && attrs.x !== undefined && attrs.y !== undefined) { // The preview expands into a , but disk still owns // the source . Keep the browser geometry in sync while the staged // edit writes x/y back to the real placeholder instead of persisting a // transform. var m = ownMatrix(el); el.setAttribute("transform", "matrix(" + [ formatCoord(m.a), formatCoord(m.b), formatCoord(m.c), formatCoord(m.d), formatCoord(parseFloat(attrs.x)), formatCoord(parseFloat(attrs.y)) ].join(",") + ")"); el.setAttribute("data-use-x", attrs.x); el.setAttribute("data-use-y", attrs.y); } Object.keys(attrs).forEach(function (k) { if (localName(el) === "g" && el.hasAttribute("data-icon") && (k === "x" || k === "y" || k === "transform")) return; if (attrs[k] === null || attrs[k] === undefined) el.removeAttribute(k); else el.setAttribute(k, attrs[k]); }); } function localName(el) { return el && el.tagName ? el.tagName.toLowerCase() : ""; } function markCommittedAttrs(el, attrs) { if (attrs && attrs.transform === null && localName(el) === "g" && el.hasAttribute("data-icon")) { el.removeAttribute("data-use-has-transform"); } } function payloadForCurrentElementState(el, payload) { if (!payload || !payload.attrs || payload.attrs.transform !== null || localName(el) !== "g" || !el.hasAttribute("data-icon") || el.hasAttribute("data-use-has-transform")) { return payload; } var attrs = Object.assign({}, payload.attrs); delete attrs.transform; return Object.assign({}, payload, { attrs: attrs }); } function restoreDragStart(el) { applyAttrSnapshot(el, dragStartAttrs[el.id]); } function applyMoveLocal(el, dxScreen, dyScreen) { // Each element converts the shared screen delta into its own local space, // so a selection spanning differently-transformed groups still tracks the // pointer. Restore-then-apply keeps every frame relative to the start. restoreDragStart(el); var d = screenDeltaToLocal(el, dxScreen, dyScreen); var attrs; try { attrs = attrsForMove(el, d.x, d.y); } catch (err) { attrs = null; } if (!attrs) return null; applyElementAttrs(el, attrs); return attrs; } function updateDragPreview(dxScreen, dyScreen) { dragEls.forEach(function (el) { applyMoveLocal(el, dxScreen, dyScreen); }); if (dragEls.length === 1) refreshGeoInputs(dragEls[0], elementPropsEl); } function commitDrag(dxScreen, dyScreen) { var single = dragEls.length === 1; dragEls.forEach(function (el) { // Capture the pre-drag snapshot in the closure: endDrag() clears // dragStartAttrs synchronously after this call, before the staging // promise can reject, so the rollback can't rely on the global. var startAttrs = dragStartAttrs[el.id]; var attrs = applyMoveLocal(el, dxScreen, dyScreen); if (!attrs) return; var payload = payloadForMovedElement(el, attrs); stageEditRequest(el.id, payload).then(function () { if (payload.attrs) markCommittedAttrs(el, payload.attrs); if (payload.promote_tspan) { var promoted = promoteTspanDom(el, payload.promote_tspan); if (promoted && single) refreshGeoInputs(promoted, elementPropsEl); } }).catch(function (err) { // Optimistic preview already moved the element; roll it back so the // canvas never shows a move the server never staged. applyAttrSnapshot(el, startAttrs); if (single) refreshGeoInputs(el, elementPropsEl); showError(t("err_edit") + err.message); }); }); } function endDrag() { dragStart = null; dragMoved = false; dragEls = []; dragStartAttrs = {}; document.body.classList.remove("svg-dragging"); } // Per-element nudge staging is serialized: a held arrow key fires many // keydowns, each sending an *absolute* position. Without ordering, the // unlocked backend could coalesce them out of order and leave the staged // value behind the on-screen one. So at most one nudge stage per element is // in flight; extra keypresses only update the preview and the queued target, // and the next send reads the current DOM position (= the latest). var nudgeFlight = {}; // key -> bool: a nudge stage is in flight var nudgeQueued = {}; // key -> {el, single, slide, attrs}: latest target awaiting send var nudgeLastOk = {}; // key -> attrs: last position the server confirmed function nudgeKey(slide, el) { return slide + "|" + el.id; } function sendNudge(key, el, single, slide, payload) { if (!payload) return; payload = payloadForCurrentElementState(el, payload); nudgeFlight[key] = true; stageEditRequest(el.id, payload, slide).then(function () { if (payload.attrs) nudgeLastOk[key] = payload.attrs; if (payload.attrs) markCommittedAttrs(el, payload.attrs); nudgeFlight[key] = false; var q = nudgeQueued[key]; if (q) { delete nudgeQueued[key]; sendNudge(key, q.el, q.single, q.slide, q.payload); } }).catch(function (err) { nudgeFlight[key] = false; delete nudgeQueued[key]; // Re-sync the preview to the last confirmed position so it can't show // a move the server never staged (only meaningful on the live slide). if (payload.promote_tspan && el.__promotionRollback) { var restored = el.__promotionRollback(); if (single && restored) refreshGeoInputs(restored, elementPropsEl); } else if (nudgeLastOk[key] && slide === currentSlide && el.isConnected) { applyAttrSnapshot(el, nudgeLastOk[key]); if (single) refreshGeoInputs(el, elementPropsEl); } showError(t("err_edit") + err.message); }); } function stageNudge(el, single, slide, payload) { var key = nudgeKey(slide, el); if (nudgeFlight[key]) { nudgeQueued[key] = { el: el, single: single, slide: slide, payload: payload }; return; } sendNudge(key, el, single, slide, payload); } function nudgeSelection(dxScreen, dyScreen) { // Keyboard arrow micro-move: same geometry path as drag. The DOM updates // immediately; staging is serialized per element (see sendNudge), and // consecutive same-direction nudges coalesce into one undo step. var els = topLevelSelectedElements(Array.from(selectedElementIds)); if (els.length === 0) return; var slide = currentSlide; var single = els.length === 1; var lastSingleEl = null; els.forEach(function (el) { var key = nudgeKey(slide, el); if (!nudgeFlight[key] && !nudgeQueued[key]) { // Fresh burst: re-capture the pre-move baseline from the current // DOM, so a failed nudge rolls back to *this* burst's start rather // than a stale position left by an earlier drag/edit/undo. try { nudgeLastOk[key] = attrsForMove(el, 0, 0); } catch (err) { delete nudgeLastOk[key]; } } var d = screenDeltaToLocal(el, dxScreen, dyScreen); var attrs; try { attrs = attrsForMove(el, d.x, d.y); } catch (err) { attrs = null; } if (!attrs) return; applyElementAttrs(el, attrs); var payload = payloadForMovedElement(el, attrs); if (payload.promote_tspan) { el = promoteTspanDom(el, payload.promote_tspan) || el; } lastSingleEl = el; stageNudge(el, single, slide, payload); }); if (single && lastSingleEl) refreshGeoInputs(lastSingleEl, elementPropsEl); } // ---- Overlap picker (right-click → list every selectable element here) ---- // Left-click stays a fast "select the topmost" gesture; right-click is the // opt-in disambiguator for stacked elements, so it never pops on normal use. var overlapPickerEl = null; function collectSelectableAt(clientX, clientY) { var svg = svgContent.querySelector("svg"); if (!svg || !document.elementsFromPoint) return []; var out = []; var seen = {}; document.elementsFromPoint(clientX, clientY).forEach(function (node) { if (!svg.contains(node) || node === svg) return; if (node.closest && node.closest("defs, style, title, desc")) return; // Resolve to the disk-backed selectable entity, mirroring the click // handler: prefer an enclosing data-icon group, else nearest id. var picked = (node.closest && node.closest("[data-icon][id]")) || (node.closest && node.closest("[id]")); if (!picked || picked === svg || !picked.id || seen[picked.id]) return; seen[picked.id] = true; out.push(picked); }); return out; // top → bottom in paint order } function closeOverlapPicker() { if (!overlapPickerEl) return; var svg = svgContent.querySelector("svg"); if (svg) { svg.querySelectorAll(".overlap-hover").forEach(function (e) { e.classList.remove("overlap-hover"); }); } overlapPickerEl.remove(); overlapPickerEl = null; } function showOverlapPicker(clientX, clientY, candidates) { closeOverlapPicker(); var menu = document.createElement("div"); menu.id = "overlap-picker"; var cap = document.createElement("div"); cap.className = "overlap-caption"; cap.textContent = t("overlap_caption"); menu.appendChild(cap); candidates.forEach(function (el) { var item = document.createElement("div"); item.className = "overlap-item"; var tag = el.tagName.toLowerCase(); var txt = (tag === "text" || tag === "tspan") ? (el.textContent || "").trim().slice(0, 28) : ""; item.innerHTML = '<' + escapeHtml(tag) + '>' + escapeHtml(el.id) + '' + (txt ? '' + escapeHtml(txt) + '' : ''); item.addEventListener("mouseenter", function () { el.classList.add("overlap-hover"); }); item.addEventListener("mouseleave", function () { el.classList.remove("overlap-hover"); }); item.addEventListener("click", function (ev) { ev.stopPropagation(); selectElement(el, ev.ctrlKey || ev.metaKey); closeOverlapPicker(); }); menu.appendChild(item); }); document.body.appendChild(menu); // Keep the menu inside the viewport. var x = Math.min(clientX, window.innerWidth - menu.offsetWidth - 8); var y = Math.min(clientY, window.innerHeight - menu.offsetHeight - 8); menu.style.left = Math.max(8, x) + "px"; menu.style.top = Math.max(8, y) + "px"; overlapPickerEl = menu; } function initRubberBand() { var overlay = document.getElementById("rubber-band-overlay"); var container = document.getElementById("svg-container"); container.addEventListener("mousedown", function (e) { // Only left mouse button if (e.button !== 0) return; // Pressing on an already-selected element arms a drag of the whole // selection instead of a rubber band. Selecting stays a separate // click, so the background is never dragged by accident. if (hitSelectedAncestor(e.target)) { dragStart = { x: e.clientX, y: e.clientY }; dragMoved = false; return; } // Always start tracking — rubber band only activates when // mousemove exceeds the threshold. This allows clicking on any // element (including SVG background rects) to still trigger // the element's click handler for selection. rubberBandStart = { x: e.clientX, y: e.clientY }; rubberBandUsed = false; }); document.addEventListener("mousemove", function (e) { if (dragStart) { var ddx = e.clientX - dragStart.x; var ddy = e.clientY - dragStart.y; if (!dragMoved && Math.sqrt(ddx * ddx + ddy * ddy) < RUBBER_BAND_THRESHOLD) { return; // below threshold — still a click, let selection stand } if (!dragMoved) { dragMoved = true; beginDrag(); } updateDragPreview(ddx, ddy); return; } if (!rubberBandStart) return; var dx = e.clientX - rubberBandStart.x; var dy = e.clientY - rubberBandStart.y; var dist = Math.sqrt(dx * dx + dy * dy); if (dist < RUBBER_BAND_THRESHOLD) { return; } // Threshold exceeded — this is a drag, not a click if (!rubberBandUsed) { rubberBandUsed = true; overlay.classList.add("active"); } if (!rubberBandEl) { rubberBandEl = document.createElement("div"); rubberBandEl.id = "rubber-band"; document.body.appendChild(rubberBandEl); } var x = Math.min(rubberBandStart.x, e.clientX); var y = Math.min(rubberBandStart.y, e.clientY); var w = Math.abs(dx); var h = Math.abs(dy); rubberBandEl.style.left = x + "px"; rubberBandEl.style.top = y + "px"; rubberBandEl.style.width = w + "px"; rubberBandEl.style.height = h + "px"; }); document.addEventListener("mouseup", function (e) { if (dragStart) { if (dragMoved) { commitDrag(e.clientX - dragStart.x, e.clientY - dragStart.y); // Swallow the click that follows the drag-release so it does // not re-trigger selection on the dropped element. suppressNextSvgClick = true; window.setTimeout(function () { suppressNextSvgClick = false; }, 50); } endDrag(); return; } if (!rubberBandStart) return; overlay.classList.remove("active"); var dx = e.clientX - rubberBandStart.x; var dy = e.clientY - rubberBandStart.y; var dist = Math.sqrt(dx * dx + dy * dy); if (rubberBandEl) { rubberBandEl.remove(); rubberBandEl = null; } // Only process if drag was beyond threshold if (dist >= RUBBER_BAND_THRESHOLD) { var rect = { left: Math.min(rubberBandStart.x, e.clientX), top: Math.min(rubberBandStart.y, e.clientY), right: Math.max(rubberBandStart.x, e.clientX), bottom: Math.max(rubberBandStart.y, e.clientY) }; if (!e.ctrlKey && !e.metaKey) { clearSelection(); } selectByRubberBand(rect); suppressNextSvgClick = true; window.setTimeout(function () { suppressNextSvgClick = false; }, 50); } else { // Below threshold: treat as click on empty space if (!e.ctrlKey && !e.metaKey) { clearSelection(); } } rubberBandStart = null; }); // Right-click → list every selectable element under the pointer, so // stacked/overlapping shapes can be reached without blind cycling. container.addEventListener("contextmenu", function (e) { var svg = svgContent.querySelector("svg"); if (!svg) return; var cands = collectSelectableAt(e.clientX, e.clientY); if (cands.length === 0) return; // nothing here → native menu e.preventDefault(); if (cands.length === 1) { selectElement(cands[0], false); return; } showOverlapPicker(e.clientX, e.clientY, cands); }); // Dismiss the picker on any press outside it (item clicks stop their own // propagation, so this never fires before a selection is made). document.addEventListener("mousedown", function (e) { if (overlapPickerEl && !overlapPickerEl.contains(e.target)) { closeOverlapPicker(); } }); } function cancelRubberBand() { rubberBandStart = null; if (rubberBandEl) { rubberBandEl.remove(); rubberBandEl = null; } var ov = document.getElementById("rubber-band-overlay"); if (ov) ov.classList.remove("active"); suppressNextSvgClick = false; endDrag(); closeOverlapPicker(); } function selectByRubberBand(screenRect) { var svg = svgContent.querySelector("svg"); if (!svg) return; var selectableEls = svg.querySelectorAll(".svg-selectable"); selectableEls.forEach(function (el) { try { var bbox = el.getBBox(); var ctm = el.getScreenCTM(); if (!ctm) return; // Transform bbox corners to screen coordinates var corners = [ { x: bbox.x, y: bbox.y }, { x: bbox.x + bbox.width, y: bbox.y }, { x: bbox.x, y: bbox.y + bbox.height }, { x: bbox.x + bbox.width, y: bbox.y + bbox.height } ]; var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; corners.forEach(function (c) { var sx = c.x * ctm.a + c.y * ctm.c + ctm.e; var sy = c.x * ctm.b + c.y * ctm.d + ctm.f; if (sx < minX) minX = sx; if (sy < minY) minY = sy; if (sx > maxX) maxX = sx; if (sy > maxY) maxY = sy; }); // AABB intersection if (minX < screenRect.right && maxX > screenRect.left && minY < screenRect.bottom && maxY > screenRect.top) { var eid = el.id; if (eid) { selectedElementIds.add(eid); el.classList.add("svg-selected"); } } } catch (err) { // getBBox can throw for elements with no geometry } }); updateSelectionPanel(); } // ================================================================ // Keyboard shortcuts // ================================================================ function initKeyboardShortcuts() { document.addEventListener("keydown", function (e) { // Ctrl+Z / Cmd+Z: drop the last staged edit. Let inputs/selects handle // their own native undo when focused. if ((e.ctrlKey || e.metaKey) && (e.key === "z" || e.key === "Z")) { var ae = document.activeElement; if (ae && (ae === annotationText || ae.tagName === "INPUT" || ae.tagName === "TEXTAREA" || ae.tagName === "SELECT")) { return; } e.preventDefault(); runUndo(); return; } // Ctrl+A / Cmd+A: select all elements if ((e.ctrlKey || e.metaKey) && e.key === "a") { // Don't intercept if focus is in textarea if (document.activeElement === annotationText) return; e.preventDefault(); var svg = svgContent.querySelector("svg"); if (!svg) return; svg.querySelectorAll(".svg-selectable").forEach(function (el) { var eid = el.id; if (eid) { selectedElementIds.add(eid); el.classList.add("svg-selected"); } }); updateSelectionPanel(); } // Escape: close the overlap picker first if open, else clear selection. if (e.key === "Escape") { if (document.activeElement === annotationText) return; if (overlapPickerEl) { closeOverlapPicker(); return; } clearSelection(); } // Slide navigation: ArrowLeft/Right + Home/End (skip while typing) if (document.activeElement === annotationText) return; if (e.ctrlKey || e.metaKey || e.altKey) return; // Arrow keys nudge the selection (1px, Shift = 10px) instead of // navigating slides whenever something is selected. if (selectedElementIds.size > 0 && (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown")) { e.preventDefault(); var step = e.shiftKey ? 10 : 1; var nx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0; var ny = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0; nudgeSelection(nx, ny); return; } if (slideNames.length === 0) return; if (e.key === "ArrowLeft") { e.preventDefault(); gotoSlideIndex(currentSlideIndex() - 1); } else if (e.key === "ArrowRight") { e.preventDefault(); gotoSlideIndex(currentSlideIndex() + 1); } else if (e.key === "Home") { e.preventDefault(); gotoSlideIndex(0); } else if (e.key === "End") { e.preventDefault(); gotoSlideIndex(slideNames.length - 1); } }); } function initSlideNav() { if (navFirstBtn) navFirstBtn.addEventListener("click", function () { gotoSlideIndex(0); }); if (navPrevBtn) navPrevBtn.addEventListener("click", function () { gotoSlideIndex(currentSlideIndex() - 1); }); if (navNextBtn) navNextBtn.addEventListener("click", function () { gotoSlideIndex(currentSlideIndex() + 1); }); if (navLastBtn) navLastBtn.addEventListener("click", function () { gotoSlideIndex(slideNames.length - 1); }); } // ================================================================ // 6. Add annotation -- POST /api/slide/{name}/annotate // ================================================================ btnAddAnnotation.addEventListener("click", function () { if (!currentSlide || selectedElementIds.size === 0) return; var text = annotationText.value.trim(); if (!text) return; var ids = Array.from(selectedElementIds); var promises = ids.map(function (eid) { return fetch("/api/slide/" + encodeURIComponent(currentSlide) + "/annotate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ element_id: eid, annotation: text }) }).then(jsonOrThrow); }); Promise.all(promises) .then(function () { ids.forEach(function (eid) { slideAnnotations[eid] = text; }); markAnnotationChanged(currentSlide); refreshAnnotationVisuals(); updateAnnotationList(); annotationText.value = ""; loadSlides(); }) .catch(function (err) { console.error("addAnnotation:", err); showError(t("err_add_annotation") + err.message); }); }); // ================================================================ // 7. removeAnnotation -- DELETE /api/slide/{name}/annotate/{id} // ================================================================ function removeAnnotation(elementId) { if (!currentSlide) return; fetch("/api/slide/" + encodeURIComponent(currentSlide) + "/annotate/" + encodeURIComponent(elementId), { method: "DELETE" }) .then(function (res) { return res.json(); }) .then(function () { delete slideAnnotations[elementId]; markAnnotationChanged(currentSlide); refreshAnnotationVisuals(); updateAnnotationList(); loadSlides(); }) .catch(function (err) { console.error("removeAnnotation:", err); showError(t("err_remove_annotation") + err.message); }); } // ================================================================ // 8. refreshAnnotationVisuals // ================================================================ function refreshAnnotationVisuals() { // Clear all annotated marks svgContent.querySelectorAll(".svg-annotated").forEach(function (el) { el.classList.remove("svg-annotated"); }); // Apply marks Object.keys(slideAnnotations).forEach(function (eid) { var el = svgContent.querySelector("#" + CSS.escape(eid)); if (el) el.classList.add("svg-annotated"); }); } // ================================================================ // 9. updateAnnotationList // ================================================================ function updateAnnotationList() { annotationsEl.innerHTML = ""; var ids = Object.keys(slideAnnotations); if (ids.length === 0) { annotationsEl.innerHTML = '
' + escapeHtml(t("empty_annotations")) + '
'; return; } ids.forEach(function (eid) { var item = document.createElement("div"); item.className = "annotation-item"; // Try to resolve tag from live SVG var tag = ""; var el = svgContent.querySelector("#" + CSS.escape(eid)); if (el) tag = el.tagName.toLowerCase(); var header = document.createElement("div"); header.className = "ann-header"; var leftSpan = document.createElement("span"); if (tag) { var tagSpan = document.createElement("span"); tagSpan.className = "ann-tag"; tagSpan.textContent = "<" + tag + ">"; leftSpan.appendChild(tagSpan); } var idSpan = document.createElement("span"); idSpan.className = "ann-id"; idSpan.textContent = eid; leftSpan.appendChild(idSpan); header.appendChild(leftSpan); var removeBtn = document.createElement("button"); removeBtn.className = "ann-remove"; removeBtn.innerHTML = "×"; removeBtn.title = t("tooltip_remove_annotation"); removeBtn.addEventListener("click", function () { removeAnnotation(eid); }); header.appendChild(removeBtn); item.appendChild(header); var textDiv = document.createElement("div"); textDiv.className = "ann-text"; textDiv.textContent = slideAnnotations[eid]; item.appendChild(textDiv); annotationsEl.appendChild(item); }); } // ================================================================ // 10. Save all -- two-step: confirm then save // ================================================================ btnSave.addEventListener("click", function () { pendingModalAction = "submit"; modalMessage.textContent = t("modal_confirm_submit") + (hasPendingMatrixEdits() ? t("modal_matrix_transform_note") : ""); modalConfirm.textContent = t("modal_submit"); modalConfirm.style.display = ""; modalCancel.style.display = ""; modalOverlay.style.display = "flex"; }); btnExitPreview.addEventListener("click", function () { pendingModalAction = "exit"; modalMessage.textContent = t("modal_confirm_exit"); modalConfirm.textContent = t("btn_exit_preview"); modalConfirm.style.display = ""; modalCancel.style.display = ""; modalOverlay.style.display = "flex"; }); modalConfirm.addEventListener("click", function () { if (pendingModalAction === "exit") { modalConfirm.style.display = "none"; modalCancel.style.display = "none"; modalMessage.textContent = t("modal_stopping"); fetch("/api/shutdown", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "exit-preview" }) }) .then(function () { modalMessage.textContent = t("modal_success_exit"); }) .catch(function () { modalMessage.textContent = t("modal_success_exit"); }); return; } // Step 2: save annotations. Service lifetime is controlled only by Exit preview. var hadDirectEdits = pendingDirectEditCount() > 0; var hadAnnotationChanges = pendingAnnotationChangeCount() > 0; modalConfirm.style.display = "none"; modalCancel.style.display = "none"; // Wait for any just-fired direct edits (drag/nudge) to land first, or // save-all could race ahead and leave their edits as a fresh pending set. drainDirectEdits() .then(function () { return fetch("/api/save-all", { method: "POST" }); }) .then(function (res) { return res.json(); }) .then(function (data) { if (data.error) { modalMessage.textContent = t("err_save") + data.error; } else { if (hadDirectEdits && hadAnnotationChanges) { modalMessage.textContent = t("modal_success_mixed"); } else if (hadDirectEdits) { modalMessage.textContent = t("modal_success_direct_only"); } else if (hadAnnotationChanges) { modalMessage.textContent = t("modal_success_annotations_only"); } else { modalMessage.textContent = t("modal_success_submit"); } editStackCount = {}; matrixEditSlides = {}; matrixHintShown = false; savedHintShown = false; annotationsDirty = false; annotationEditSlides = {}; updateUndoButton(); updatePendingStatus(); var activeSlide = currentSlide; loadSlides().then(function () { if (activeSlide) { var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(activeSlide) + '"]'); selectSlide(activeSlide, item || undefined); } }); } }) .catch(function (err) { modalMessage.textContent = t("err_save") + err; modalConfirm.style.display = ""; modalCancel.style.display = ""; }); }); modalCancel.addEventListener("click", function () { modalConfirm.textContent = t("modal_submit"); modalOverlay.style.display = "none"; }); // Close modal on overlay click modalOverlay.addEventListener("click", function (e) { if (e.target === modalOverlay) { modalConfirm.textContent = t("modal_submit"); modalOverlay.style.display = "none"; } }); // ================================================================ // Utility // ================================================================ function sanitizeSvg(svgString) { var doc = new DOMParser().parseFromString(svgString, "image/svg+xml"); doc.querySelectorAll("script,foreignObject").forEach(function (el) { el.remove(); }); doc.querySelectorAll("*").forEach(function (el) { Array.from(el.attributes).forEach(function (attr) { if (attr.name.indexOf("on") === 0) el.removeAttribute(attr.name); // Strip dangerous URI protocols from href/xlink:href if (attr.localName === "href" && (/^\s*javascript\s*:/i.test(attr.value) || /^\s*data\s*:/i.test(attr.value))) { el.removeAttributeNode(attr); } }); }); return new XMLSerializer().serializeToString(doc.documentElement); } function showError(msg) { var banner = document.createElement("div"); 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;"; banner.textContent = msg; banner.onclick = function () { banner.remove(); }; document.body.appendChild(banner); setTimeout(function () { banner.remove(); }, 5000); } function showWarning(msg) { // Amber, non-fatal counterpart to showError. Stacks below an existing // error banner because z-index is identical and DOM order wins. var banner = document.createElement("div"); 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;"; banner.textContent = msg; banner.onclick = function () { banner.remove(); }; document.body.appendChild(banner); setTimeout(function () { banner.remove(); }, 6000); } function showReloadBanner(name) { // Singleton: replace any prior banner so we never stack reloads. hideReloadBanner(); var banner = document.createElement("div"); banner.id = "reload-banner"; 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;"; banner.textContent = t("reload_banner"); banner.onclick = function () { hideReloadBanner(); // Re-fetch via selectSlide so all post-load logic (annotation merge, // warnings, mtime update) runs the same way as a manual click. var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(name) + '"]'); selectSlide(name, item || undefined); }; document.body.appendChild(banner); reloadBannerEl = banner; } function hideReloadBanner() { if (reloadBannerEl) { reloadBannerEl.remove(); reloadBannerEl = null; } } function escapeHtml(str) { var d = document.createElement("div"); d.appendChild(document.createTextNode(str)); return d.innerHTML; } function jsonOrThrow(res) { return res.json().then(function (data) { if (!res.ok || data.error) { throw new Error(data.error || ("Request failed with status " + res.status)); } return data; }); } function loadConfig() { return fetch("/api/config") .then(function (res) { return res.json(); }) .then(function (data) { liveMode = !!data.live; }) .catch(function () { liveMode = false; }); } function startSlidePolling() { if (!liveMode || slidePollTimer) return; slidePollTimer = window.setInterval(function () { loadSlides(); }, 2000); } // ---- Direct-edit undo + save hint -------------------------------- function updateUndoButton() { if (!btnUndo) return; var n = (currentSlide && editStackCount[currentSlide]) || 0; btnUndo.disabled = n === 0; btnUndo.textContent = n > 0 ? (t("btn_undo") + " (" + n + ")") : t("btn_undo"); } function runUndo() { if (!currentSlide || !editStackCount[currentSlide]) return; fetch("/api/slide/" + encodeURIComponent(currentSlide) + "/undo", { method: "POST" }) .then(jsonOrThrow) .then(function (data) { if (data.status === "empty") { editStackCount[currentSlide] = 0; matrixEditSlides[currentSlide] = false; updateUndoButton(); updatePendingStatus(); showWarning(t("undo_empty")); return; } if (data.mtime !== undefined && data.mtime !== null) { slideMtimes[currentSlide] = data.mtime; } // Reload the page so the canvas reflects the reverted SVG; // selectSlide refreshes editStackCount + the undo button. var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(currentSlide) + '"]'); selectSlide(currentSlide, item || undefined); updatePendingStatus(); showWarning(t("undo_done")); }) .catch(function (err) { showError(t("err_edit") + err.message); }); } function maybeShowSavedHint() { if (savedHintShown) return; savedHintShown = true; showWarning(t("edit_saved_hint")); } function payloadHasMatrixTransform(payload) { var transform = payload && payload.attrs && payload.attrs.transform; return typeof transform === "string" && /^\s*matrix\s*\(/i.test(transform); } function hasPendingMatrixEdits() { return Object.keys(matrixEditSlides).some(function (name) { return !!matrixEditSlides[name]; }); } function markMatrixTransformEdit(slide) { matrixEditSlides[slide] = true; if (matrixHintShown) return; matrixHintShown = true; showWarning(t("warn_matrix_transform")); } // ================================================================ // Property extraction & rendering // ================================================================ // Editable property panel for a single selected element: computed geometry, // text content where safe, and the element's own SVG attributes. function renderEditableProps(el) { var tag = localName(el); var isGroup = tag === "g"; var html = '
' + escapeHtml(isGroup ? t("label_group_edit") : t("label_direct_edit")) + '
'; var parentGroup = nearestParentGroup(el); if (parentGroup && !isGroup) { html += '
group' + '
'; } html += '
' + escapeHtml(t("section_geometry")) + '
'; html += ''; // Geometry: position + size in render coords — editable, folded into // the element's transform matrix on commit (works for any element type). try { var rb = computeRenderBox(el); html += ''; html += ''; } catch (e) { /* no geometry */ } html += '
position' + '' + '' + '
size' + '' + '' + '
'; var textStyleTargets = textStyleTargetsForSelection(el); // L1: text content + computed text styles. When a textbox/group is // selected, style controls target its descendant text leaves. if (tag === "text" || tag === "tspan" || textStyleTargets.length > 0) { html += '
' + escapeHtml(t("section_text_style")) + '
'; html += ''; if ((tag === "text" || tag === "tspan") && el.querySelector("tspan")) { html += ''; } else if (tag === "text" || tag === "tspan") { html += ''; } if (textStyleTargets.length > 0) { textStyleSpecs(textStyleTargets).forEach(function (spec) { html += ''; }); } html += '
content' + escapeHtml(t("prop_multiline_hint")) + '
content' + '
' + escapeHtml(spec.key) + ''; html += renderTextStyleControl(spec, textStyleTargets); html += '
'; } html += '
' + escapeHtml(t("section_raw_attrs")) + '
'; html += ''; ownAttributeSpecs(el).forEach(function (spec) { html += ''; }); html += '
' + escapeHtml(spec.key) + ''; if (!spec.editable) { html += '' + escapeHtml(spec.value) + ''; } else { html += renderAttributeControl(spec); } html += '
'; return html; } function nearestParentGroup(el) { var node = el ? el.parentElement : null; while (node && node !== svgContent) { if (node.tagName && localName(node) === "g" && node.id) return node; node = node.parentElement; } return null; } function ownAttributeSpecs(el) { var specs = []; Array.from(el.attributes).forEach(function (attr) { var key = attr.name; if (key === "class" || key === "data-edit-target" || key === "data-edit-annotation") return; specs.push({ key: key, value: attr.value, editable: isEditableAttributeName(key) }); }); return specs; } function isEditableAttributeName(key) { var k = String(key).toLowerCase(); if (k === "id" || k === "class" || k === "href" || k === "xlink:href") return false; if (k.indexOf("on") === 0) return false; return /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(key); } function textStyleTargetsForSelection(el) { if (!el) return []; var tag = localName(el); if (tag === "tspan") return el.id ? [el] : []; if (tag === "text") return textStyleLeavesForText(el); var targets = []; el.querySelectorAll("text").forEach(function (textEl) { targets = targets.concat(textStyleLeavesForText(textEl)); }); return uniqueElements(targets); } function textStyleLeavesForText(textEl) { var tspans = Array.from(textEl.querySelectorAll("tspan")).filter(function (tspan) { return !!tspan.id; }); if (tspans.length > 0) return tspans; return textEl.id ? [textEl] : []; } function uniqueElements(elements) { var seen = new Set(); return elements.filter(function (el) { if (!el || !el.id || seen.has(el.id)) return false; seen.add(el.id); return true; }); } function textStyleSpecs(targets) { return [ { key: "fill", type: "color" }, { key: "font-size", type: "text" }, { key: "font-family", type: "text" }, { key: "font-weight", type: "select", options: ["", "normal", "bold", "300", "400", "500", "600", "700", "800"] }, { key: "text-anchor", type: "select", options: ["", "start", "middle", "end"] } ].map(function (spec) { var value = commonAttrValue(targets, spec.key); var copy = Object.assign({}, spec); copy.value = value === null ? "" : value; copy.mixed = value === null; return copy; }); } function renderTextStyleControl(spec, targets) { var targetIds = encodeURIComponent(JSON.stringify(targets.map(function (el) { return el.id; }))); var placeholder = spec.mixed ? t("multi_mixed") : ""; if (spec.type === "color") { return '' + ''; } if (spec.type === "select") { var html = ''; return html; } return ''; } function isColorAttribute(key, value) { var k = String(key).toLowerCase(); if (["fill", "stroke", "color", "stop-color", "flood-color", "lighting-color"].indexOf(k) !== -1) { return isSafeColor(value); } return false; } function renderAttributeControl(spec) { if (isColorAttribute(spec.key, spec.value)) { return '' + ''; } if (spec.value.length > 60 || spec.key === "d" || spec.key === "points" || spec.key === "style") { return ''; } return ''; } // ---- Geometry (position/size) helpers ---------------------------- // Edits fold into the element's own transform matrix so they apply // uniformly regardless of how the element is positioned (x/y, cx/cy, d…). function computeRenderBox(el) { // Local geometry bbox (excludes the element's OWN transform) ... var bbox = el.getBBox(); // ... times the element CTM (includes own + ancestor transforms) = // the on-screen box in the svg root user space (= svg_output coords). var ctm = el.getCTM(); if (!ctm) return { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height }; var corners = [ { x: bbox.x, y: bbox.y }, { x: bbox.x + bbox.width, y: bbox.y }, { x: bbox.x, y: bbox.y + bbox.height }, { x: bbox.x + bbox.width, y: bbox.y + bbox.height } ]; var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; corners.forEach(function (p) { var sx = p.x * ctm.a + p.y * ctm.c + ctm.e; var sy = p.x * ctm.b + p.y * ctm.d + ctm.f; minX = Math.min(minX, sx); minY = Math.min(minY, sy); maxX = Math.max(maxX, sx); maxY = Math.max(maxY, sy); }); return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; } function ownMatrix(el) { var t = el.transform && el.transform.baseVal; if (t && t.numberOfItems) { var c = t.consolidate(); if (c) { var m = c.matrix; return new DOMMatrix([m.a, m.b, m.c, m.d, m.e, m.f]); } } return new DOMMatrix(); } function parentCTM(el) { // parent → svg-root user space; identity when parent is the outer . var p = el.parentNode; var pm = (p && p.getCTM) ? p.getCTM() : null; return pm ? new DOMMatrix([pm.a, pm.b, pm.c, pm.d, pm.e, pm.f]) : new DOMMatrix(); } function matrixAfterMove(el, rb, nx, ny) { // Desired shift in root coords, mapped into parent space (linear part only). var pm = parentCTM(el); var rx = nx - rb.x, ry = ny - rb.y; var det = pm.a * pm.d - pm.b * pm.c || 1; var dpx = (pm.d * rx - pm.c * ry) / det; var dpy = (-pm.b * rx + pm.a * ry) / det; return new DOMMatrix().translate(dpx, dpy).multiply(ownMatrix(el)); } function matrixAfterResize(el, rb, nw, nh) { var pm = parentCTM(el); var sx = nw / rb.w, sy = nh / rb.h; // Anchor = render top-left, mapped to parent coords (full inverse). var im = pm.inverse(); var ax = im.a * rb.x + im.c * rb.y + im.e; var ay = im.b * rb.x + im.d * rb.y + im.f; return new DOMMatrix() .translate(ax, ay).scale(sx, sy).translate(-ax, -ay) .multiply(ownMatrix(el)); } function matrixToString(m) { var r = function (v) { return Math.round(v * 1e6) / 1e6; }; var values = [m.a, m.b, m.c, m.d, m.e, m.f].map(r); if (!values.every(function (v) { return Number.isFinite(v); })) return null; return "matrix(" + values.join(",") + ")"; } function refreshGeoInputs(el, panel) { var rb = computeRenderBox(el); var vals = { x: Math.round(rb.x), y: Math.round(rb.y), w: Math.round(rb.w), h: Math.round(rb.h) }; Object.keys(vals).forEach(function (k) { var inp = panel.querySelector('.prop-edit-geo[data-geo="' + k + '"]'); if (inp && document.activeElement !== inp) inp.value = vals[k]; }); } function normalizeHexForPicker(val) { var s = String(val).trim(); if (/^#[0-9a-fA-F]{6}$/.test(s)) return s; if (/^#[0-9a-fA-F]{3}$/.test(s)) { return "#" + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]; } return "#000000"; } var SVG_UNIT_OPTIONAL_LENGTH_ATTRS = new Set([ "x", "y", "x1", "x2", "y1", "y2", "cx", "cy", "r", "rx", "ry", "dx", "dy", "width", "height", "font-size", "stroke-width", "stroke-dashoffset", "letter-spacing", "word-spacing" ]); function normalizeSvgLengthValue(key, value) { var s = String(value || ""); if (!SVG_UNIT_OPTIONAL_LENGTH_ATTRS.has(String(key).toLowerCase())) return s; s = s.trim(); var m = s.match(/^(-?\d+(?:\.\d+)?)px$/i); return m ? m[1] : s; } function normalizeEditableAttrValue(key, value) { var s = String(value || "").trim(); return normalizeSvgLengthValue(key, s); } function isSafeColor(val) { // Only allow values that look like CSS colors (hex, rgb, rgba, hsl, named). // Reject anything with ; : url @ \ to prevent CSS injection. return val.length < 100 && !/[;:@\\]|url\s*\(/i.test(val); } function splitNumberUnit(value) { var m = String(value || "").trim().match(/^(-?\d+(?:\.\d+)?)([A-Za-z%]*)$/); if (!m) return null; return { num: parseFloat(m[1]), unit: m[2] || "" }; } function shiftedAttrValue(el, key, delta) { var parsed = splitNumberUnit(el.getAttribute(key)); if (!parsed || !Number.isFinite(parsed.num)) return null; var next = Math.round((parsed.num + delta) * 1000) / 1000; return String(next) + parsed.unit; } function parentDelta(dx, dy, el) { var pm = parentCTM(el); var det = pm.a * pm.d - pm.b * pm.c; if (Math.abs(det) < 1e-9) return null; return { x: (pm.d * dx - pm.c * dy) / det, y: (-pm.b * dx + pm.a * dy) / det }; } function firstNumber(value) { var m = String(value || "").match(/-?\d+(?:\.\d+)?/); return m ? parseFloat(m[0]) : null; } function formatCoord(value) { return String(Math.round(value * 1000) / 1000); } function tspanBaseline(tspan) { var text = tspan && tspan.parentElement; if (!text || localName(text) !== "text") return null; var curX = firstNumber(text.getAttribute("x")); var curY = firstNumber(text.getAttribute("y")); var children = Array.from(text.children); for (var i = 0; i < children.length; i++) { var child = children[i]; if (localName(child) !== "tspan") continue; var x = firstNumber(child.getAttribute("x")); var y = firstNumber(child.getAttribute("y")); var dx = firstNumber(child.getAttribute("dx")); var dy = firstNumber(child.getAttribute("dy")); if (x !== null) curX = x; else if (dx !== null) curX = (curX || 0) + dx; if (y !== null) curY = y; else if (dy !== null) curY = (curY || 0) + dy; if (child === tspan) break; } if (curX === null || curY === null) return null; return { x: curX, y: curY }; } function tspanPromotionPayload(tspan) { if (localName(tspan) !== "tspan") return null; var base = tspanBaseline(tspan); if (!base) return null; var own = ownMatrix(tspan); var close = function (a, b) { return Math.abs(a - b) < 1e-6; }; if (!close(own.a, 1) || !close(own.b, 0) || !close(own.c, 0) || !close(own.d, 1)) return null; return { x: formatCoord(base.x + own.e), y: formatCoord(base.y + own.f) }; } function copyTextAttrs(src, dst, skip) { Array.from(src.attributes || []).forEach(function (attr) { if (skip[attr.name]) return; dst.setAttribute(attr.name, attr.value); }); } function adjustedDyValue(oldValue, nextY, prevY) { var parsed = splitNumberUnit(oldValue); if (!parsed) return formatCoord(nextY - prevY); return formatCoord(nextY - prevY) + parsed.unit; } function promoteTspanDom(tspan, promoted) { var text = tspan && tspan.parentElement; if (!text || localName(tspan) !== "tspan" || localName(text) !== "text") return null; var next = tspan.nextElementSibling; var nextBase = next && localName(next) === "tspan" ? tspanBaseline(next) : null; var prev = tspan.previousElementSibling; var prevBase = prev && localName(prev) === "tspan" ? tspanBaseline(prev) : null; var parentBaseY = firstNumber(text.getAttribute("y")) || 0; var newText = document.createElementNS(tspan.namespaceURI, "text"); copyTextAttrs(text, newText, { id: true, x: true, y: true, dx: true, dy: true }); copyTextAttrs(tspan, newText, { id: true, x: true, y: true, dx: true, dy: true, transform: true }); newText.setAttribute("id", tspan.id); newText.setAttribute("x", promoted.x); newText.setAttribute("y", promoted.y); Array.from(tspan.childNodes || []).forEach(function (node) { newText.appendChild(node.cloneNode(true)); }); if (newText.childNodes.length === 0) { newText.textContent = tspan.textContent || ""; } var textParent = text.parentNode; var textNext = text.nextSibling; var textClone = text.cloneNode(true); newText.__promotionRollback = function () { if (newText.parentNode) newText.parentNode.removeChild(newText); if (text.parentNode) text.parentNode.removeChild(text); if (textParent) { if (textNext && textNext.parentNode === textParent) { textParent.insertBefore(textClone, textNext); } else { textParent.appendChild(textClone); } } selectedElementIds.delete(newText.id); selectedElementIds.add(tspan.id); markSelection(); return textClone.querySelector("#" + CSS.escape(tspan.id)) || textClone; }; text.parentNode.insertBefore(newText, text.nextSibling); text.removeChild(tspan); if (next && nextBase && !next.hasAttribute("y") && next.hasAttribute("dy")) { next.setAttribute("dy", adjustedDyValue( next.getAttribute("dy"), nextBase.y, prevBase ? prevBase.y : parentBaseY )); } if (!(text.textContent || "").trim() && text.children.length === 0) { text.parentNode.removeChild(text); } selectedElementIds.delete(tspan.id); selectedElementIds.add(newText.id); markSelection(); return newText; } function payloadForMovedElement(el, attrs) { var promoted = tspanPromotionPayload(el); if (promoted) return { promote_tspan: promoted }; return { attrs: attrs }; } function canUseRawGeometry(el) { var own = ownMatrix(el); var close = function (a, b) { return Math.abs(a - b) < 1e-6; }; if (close(own.b, 0) && close(own.c, 0)) { if (close(own.a, 1) && close(own.d, 1)) return "translate"; if (localName(el) === "use") return "use-transform"; if (["rect", "image"].indexOf(localName(el)) !== -1) return "box-transform"; } return ""; } function rawMoveAttrs(el, dx, dy) { if (localName(el) === "g" && el.hasAttribute("data-icon") && el.hasAttribute("data-use-x") && el.hasAttribute("data-use-y")) { var iconOwn = ownMatrix(el); var close = function (a, b) { return Math.abs(a - b) < 1e-6; }; if (close(iconOwn.b, 0) && close(iconOwn.c, 0)) { var iconDelta = parentDelta(dx, dy, el); if (iconDelta) { var iconAttrs = { x: formatCoord(iconOwn.e + iconDelta.x), y: formatCoord(iconOwn.f + iconDelta.y) }; if (el.hasAttribute("data-use-has-transform")) { iconAttrs.transform = null; } return iconAttrs; } } } var rawMode = canUseRawGeometry(el); if (!rawMode) return null; var d = parentDelta(dx, dy, el); if (!d) return null; var tag = localName(el); var attrs = {}; var own = ownMatrix(el); var add = function (key, delta) { if (!el.hasAttribute(key)) return false; var next = shiftedAttrValue(el, key, delta); if (next === null) return false; attrs[key] = next; return true; }; var fmt = function (value) { return String(Math.round(value * 1000) / 1000); }; var bakeTranslate = function (xKey, yKey) { if (!el.hasAttribute(xKey) || !el.hasAttribute(yKey)) return false; if (rawMode === "use-transform") { attrs[xKey] = fmt(own.e + d.x); attrs[yKey] = fmt(own.f + d.y); attrs.transform = null; return true; } var nx = shiftedAttrValue(el, xKey, own.e + d.x); var ny = shiftedAttrValue(el, yKey, own.f + d.y); if (nx === null || ny === null) return false; attrs[xKey] = nx; attrs[yKey] = ny; if (el.hasAttribute("transform")) attrs.transform = null; return true; }; var bakeBox = function () { if (!el.hasAttribute("x") || !el.hasAttribute("y") || !el.hasAttribute("width") || !el.hasAttribute("height")) return false; var x = splitNumberUnit(el.getAttribute("x")); var y = splitNumberUnit(el.getAttribute("y")); var w = splitNumberUnit(el.getAttribute("width")); var h = splitNumberUnit(el.getAttribute("height")); if (!x || !y || !w || !h || x.unit !== y.unit || x.unit !== w.unit || x.unit !== h.unit) return false; attrs.x = fmt(own.a * x.num + own.e + d.x) + x.unit; attrs.y = fmt(own.d * y.num + own.f + d.y) + y.unit; attrs.width = fmt(Math.abs(own.a) * w.num) + w.unit; attrs.height = fmt(Math.abs(own.d) * h.num) + h.unit; attrs.transform = null; return true; }; if (rawMode === "box-transform") { return bakeBox() ? attrs : null; } if (["rect", "image", "use", "text"].indexOf(tag) !== -1) { return bakeTranslate("x", "y") ? attrs : null; } if (tag === "circle" || tag === "ellipse") { return bakeTranslate("cx", "cy") ? attrs : null; } if (tag === "line") { if (add("x1", own.e + d.x) && add("x2", own.e + d.x) && add("y1", own.f + d.y) && add("y2", own.f + d.y)) { if (el.hasAttribute("transform")) attrs.transform = null; return attrs; } return null; } return null; } function attrsForMove(el, dx, dy) { var attrs = rawMoveAttrs(el, dx, dy); if (attrs) return attrs; var rb = computeRenderBox(el); var moved = matrixAfterMove(el, rb, rb.x + dx, rb.y + dy); var tstr = matrixToString(moved); return tstr ? { transform: tstr } : null; } function computeElementsBox(elements) { var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; elements.forEach(function (el) { var rb = computeRenderBox(el); minX = Math.min(minX, rb.x); minY = Math.min(minY, rb.y); maxX = Math.max(maxX, rb.x + rb.w); maxY = Math.max(maxY, rb.y + rb.h); }); return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; } function topLevelSelectedElements(ids) { var els = ids.map(function (eid) { return svgContent.querySelector("#" + CSS.escape(eid)); }).filter(Boolean); return els.filter(function (el) { return !els.some(function (other) { return other !== el && other.contains(el); }); }); } function attachPropEditors(el) { var eid = el.id; var panel = elementPropsEl; var groupBtn = panel.querySelector(".btn-select-group"); if (groupBtn) { groupBtn.addEventListener("click", function () { var gid = groupBtn.getAttribute("data-group-id"); var groupEl = gid ? svgContent.querySelector("#" + CSS.escape(gid)) : null; if (groupEl) selectElement(groupEl, false); }); } // L1: text content (fires on blur/change). var contentBox = panel.querySelector(".prop-edit-content"); if (contentBox) { contentBox.addEventListener("change", function () { var newText = contentBox.value; stageEditRequest(eid, { text: newText }) .then(function () { el.textContent = newText; }) .catch(function (err) { showError(t("err_edit") + err.message); }); }); } // Raw own attributes: color picker + text input stay in sync. panel.querySelectorAll(".prop-edit-color").forEach(function (picker) { var key = picker.getAttribute("data-key"); var textInput = panel.querySelector('.prop-edit-color-text[data-key="' + key + '"]'); picker.addEventListener("input", function () { if (textInput) textInput.value = picker.value; }); picker.addEventListener("change", function () { stageAttr(el, eid, key, picker.value); }); }); panel.querySelectorAll(".prop-edit-color-text").forEach(function (textInput) { var key = textInput.getAttribute("data-key"); var picker = panel.querySelector('input.prop-edit-color[data-key="' + key + '"]'); textInput.addEventListener("change", function () { var v = textInput.value.trim(); if (picker) picker.value = normalizeHexForPicker(v); stageAttr(el, eid, key, v); }); }); panel.querySelectorAll(".prop-edit-text-style-color").forEach(function (picker) { var key = picker.getAttribute("data-key"); var textInput = panel.querySelector('.prop-edit-text-style[data-key="' + key + '"]'); picker.addEventListener("input", function () { if (textInput) textInput.value = picker.value; }); picker.addEventListener("change", function () { if (textInput) textInput.value = picker.value; stageTextStyleAttr(key, picker.value, picker.getAttribute("data-target-ids")); }); }); panel.querySelectorAll(".prop-edit-text-style").forEach(function (input) { input.addEventListener("change", function () { var key = input.getAttribute("data-key"); var value = normalizeEditableAttrValue(key, input.value); if (!value) return; stageTextStyleAttr(key, value, input.getAttribute("data-target-ids")); }); }); panel.querySelectorAll(".prop-edit-attr, .prop-edit-attr-area").forEach(function (input) { var key = input.getAttribute("data-key"); input.addEventListener("change", function () { stageAttr(el, eid, key, normalizeEditableAttrValue(key, input.value)); }); }); // Geometry: position (move) + size (resize), folded into the transform. if (panel.querySelector(".prop-edit-geo")) { var readGeo = function (k) { var inp = panel.querySelector('.prop-edit-geo[data-geo="' + k + '"]'); return inp ? parseFloat(inp.value) : NaN; }; var commitGeo = function (mode) { var rb; try { rb = computeRenderBox(el); } catch (e) { return; } var newM; var attrs = null; if (mode === "move") { var nx = readGeo("x"), ny = readGeo("y"); if (isNaN(nx) || isNaN(ny)) return; if (Math.abs(nx - rb.x) < 1e-6 && Math.abs(ny - rb.y) < 1e-6) return; attrs = attrsForMove(el, nx - rb.x, ny - rb.y); if (!attrs) newM = matrixAfterMove(el, rb, nx, ny); } else { var nw = readGeo("w"), nh = readGeo("h"); if (isNaN(nw) || isNaN(nh) || nw <= 0 || nh <= 0 || rb.w <= 0 || rb.h <= 0) { showError(t("err_edit") + "size"); return; } if (Math.abs(nw - rb.w) < 1e-6 && Math.abs(nh - rb.h) < 1e-6) return; newM = matrixAfterResize(el, rb, nw, nh); } if (!attrs) { var tstr = matrixToString(newM); if (!tstr) { showError(t("err_edit") + "transform"); return; } attrs = { transform: tstr }; } var payload = mode === "move" ? payloadForMovedElement(el, attrs) : { attrs: attrs }; stageEditRequest(eid, payload) .then(function () { if (payload.promote_tspan) { el = promoteTspanDom(el, payload.promote_tspan) || el; } else { applyElementAttrs(el, attrs); markCommittedAttrs(el, attrs); } refreshGeoInputs(el, panel); }) .catch(function (err) { showError(t("err_edit") + err.message); }); }; panel.querySelectorAll('.prop-edit-geo[data-geo="x"], .prop-edit-geo[data-geo="y"]').forEach(function (inp) { inp.addEventListener("change", function () { commitGeo("move"); }); }); panel.querySelectorAll('.prop-edit-geo[data-geo="w"], .prop-edit-geo[data-geo="h"]').forEach(function (inp) { inp.addEventListener("change", function () { commitGeo("resize"); }); }); } } function stageTextStyleAttr(key, value, encodedTargetIds) { value = normalizeEditableAttrValue(key, value); if ((key === "fill" || key === "stroke") && !isSafeColor(value)) { showError(t("err_edit") + key); return; } var ids; try { ids = JSON.parse(decodeURIComponent(encodedTargetIds || "%5B%5D")); } catch (e) { ids = []; } var targets = ids.map(function (id) { return svgContent.querySelector("#" + CSS.escape(id)); }).filter(function (target) { return target && attrOrComputedValue(target, key) !== value; }); if (targets.length === 0) return; var attrs = {}; attrs[key] = value; var jobs = targets.map(function (target) { return stageEditRequest(target.id, { attrs: attrs }).then(function () { target.setAttribute(key, value); }); }); Promise.all(jobs) .then(function () { updateSelectionPanel(); }) .catch(function (err) { showError(t("err_edit") + err.message); }); } function stageAttr(el, eid, key, value) { value = normalizeEditableAttrValue(key, value); if ((key === "fill" || key === "stroke") && !isSafeColor(value)) { showError(t("err_edit") + key); return; } var attrs = {}; attrs[key] = value; stageEditRequest(eid, { attrs: attrs }) .then(function () { applyElementAttrs(el, attrs); }) .catch(function (err) { showError(t("err_edit") + err.message); }); } // Tracks every in-flight direct-edit POST so Apply changes can wait for them // to land before /save-all (otherwise a just-sent edit could arrive after the // save and resurrect a pending edit). var inFlightEdits = new Set(); function drainDirectEdits() { if (inFlightEdits.size === 0) return Promise.resolve(); return Promise.allSettled(Array.from(inFlightEdits)).then(function (results) { var failed = results.find(function (r) { return r.status === "rejected"; }); if (failed) throw (failed.reason || new Error("direct edit failed")); return drainDirectEdits(); }); } function stageEditRequest(eid, payload, slideName) { // Pin the slide at call time: a queued nudge may fire after the user has // navigated, and must still POST to the slide it was made on, not the // global currentSlide. var slide = slideName || currentSlide; var body = { element_id: eid }; if (payload.text !== undefined) body.text = payload.text; if (payload.attrs !== undefined) body.attrs = payload.attrs; if (payload.promote_tspan !== undefined) body.promote_tspan = payload.promote_tspan; var p = fetch("/api/slide/" + encodeURIComponent(slide) + "/edit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(jsonOrThrow).then(function (data) { if (data && data.undo_depth !== undefined) { editStackCount[slide] = data.undo_depth; if (slide === currentSlide) updateUndoButton(); updatePendingStatus(); } if (payloadHasMatrixTransform(payload)) { markMatrixTransformEdit(slide); } maybeShowSavedHint(); return data; }); inFlightEdits.add(p); var done = function () { inFlightEdits.delete(p); }; p.then(done, done); return p; } function renderMultiSelectSummary(ids) { var moveEls = topLevelSelectedElements(ids); var summary = '
' + escapeHtml(t("label_batch_edit")) + '
'; if (moveEls.length > 0) { try { var rb = computeElementsBox(moveEls); summary += '
' + escapeHtml(t("section_geometry")) + '
'; summary += '' + '
x / y' + '' + '' + '
'; } catch (e) { /* no multi geometry */ } } summary += '
' + escapeHtml(t("section_style")) + '
'; summary += ''; batchEditSpecs(moveEls).forEach(function (spec) { summary += ''; }); summary += '
' + escapeHtml(spec.key) + ''; summary += renderBatchControl(spec); summary += '
'; return summary; } function batchEditSpecs(elements) { var keys = [ { key: "fill", type: "color" }, { key: "stroke", type: "color" }, { key: "opacity", type: "number", min: "0", max: "1", step: "0.05" } ]; if (elements.length > 0 && elements.every(isTextElement)) { keys = keys.concat([ { key: "font-size", type: "text" }, { key: "font-family", type: "text" }, { key: "font-weight", type: "select", options: ["", "normal", "bold", "300", "400", "500", "600", "700", "800"] }, { key: "text-anchor", type: "select", options: ["", "start", "middle", "end"] } ]); } return keys.map(function (spec) { var value = commonAttrValue(elements, spec.key); var copy = Object.assign({}, spec); copy.value = value === null ? "" : value; copy.mixed = value === null; return copy; }); } function isTextElement(el) { var tag = el.tagName.toLowerCase(); return tag === "text" || tag === "tspan"; } function commonAttrValue(elements, key) { if (elements.length === 0) return ""; var first = attrOrComputedValue(elements[0], key); for (var i = 1; i < elements.length; i++) { if (attrOrComputedValue(elements[i], key) !== first) return null; } return first; } function attrOrComputedValue(el, key) { var value = el.getAttribute(key); if (value !== null && value !== "") return normalizeSvgLengthValue(key, value); var style = window.getComputedStyle(el); var map = { "fill": "fill", "stroke": "stroke", "opacity": "opacity", "font-size": "fontSize", "font-family": "fontFamily", "font-weight": "fontWeight", "text-anchor": "textAnchor" }; return normalizeSvgLengthValue(key, (map[key] && style[map[key]]) || ""); } function renderBatchControl(spec) { var placeholder = spec.mixed ? t("multi_mixed") : ""; if (spec.type === "color") { return '' + ''; } if (spec.type === "number") { return ''; } if (spec.type === "select") { var html = ''; return html; } return ''; } function attachMultiSelectionEditors(ids) { var panel = elementPropsEl; var moveEls = topLevelSelectedElements(ids); if (moveEls.length === 0) return; var readGeo = function (k) { var inp = panel.querySelector('.prop-edit-multi-geo[data-geo="' + k + '"]'); return inp ? parseFloat(inp.value) : NaN; }; var commitMove = function () { var rb; try { rb = computeElementsBox(moveEls); } catch (e) { return; } var nx = readGeo("x"), ny = readGeo("y"); if (isNaN(nx) || isNaN(ny)) return; var dx = nx - rb.x, dy = ny - rb.y; if (Math.abs(dx) < 1e-6 && Math.abs(dy) < 1e-6) return; var jobs = []; moveEls.forEach(function (el) { var attrs; try { attrs = attrsForMove(el, dx, dy); } catch (e) { attrs = null; } if (!attrs) return; var payload = payloadForMovedElement(el, attrs); jobs.push(stageEditRequest(el.id, payload).then(function () { if (payload.promote_tspan) promoteTspanDom(el, payload.promote_tspan); else { applyElementAttrs(el, attrs); markCommittedAttrs(el, attrs); } })); }); Promise.all(jobs) .then(function () { updateSelectionPanel(); }) .catch(function (err) { showError(t("err_edit") + err.message); }); }; panel.querySelectorAll(".prop-edit-multi-geo").forEach(function (inp) { inp.addEventListener("change", commitMove); }); panel.querySelectorAll(".prop-edit-batch-color").forEach(function (picker) { var key = picker.getAttribute("data-key"); var text = panel.querySelector('.prop-edit-batch[data-key="' + key + '"]'); picker.addEventListener("input", function () { if (text) text.value = picker.value; }); picker.addEventListener("change", function () { if (text) text.value = picker.value; commitBatchAttr(key, picker.value, moveEls); }); }); panel.querySelectorAll(".prop-edit-batch").forEach(function (input) { input.addEventListener("change", function () { var key = input.getAttribute("data-key"); var value = normalizeEditableAttrValue(key, input.value); if (!value) return; commitBatchAttr(key, value, moveEls); }); }); } function commitBatchAttr(key, value, elements) { value = normalizeEditableAttrValue(key, value); if ((key === "fill" || key === "stroke") && !isSafeColor(value)) { showError(t("err_edit") + key); return; } var jobs = elements.map(function (el) { var attrs = {}; attrs[key] = value; return stageEditRequest(el.id, { attrs: attrs }).then(function () { applyElementAttrs(el, attrs); }); }); Promise.all(jobs) .then(function () { updateSelectionPanel(); }) .catch(function (err) { showError(t("err_edit") + err.message); }); } // ================================================================ // Boot // ================================================================ applyI18n(); initAnnotationQuickActions(); updatePendingStatus(); var langToggleBtn = document.getElementById("btn-lang-toggle"); var langMenu = document.getElementById("lang-menu"); if (langToggleBtn && langMenu) { refreshLangUI(LANG); var setMenuOpen = function (open) { langMenu.hidden = !open; langToggleBtn.setAttribute("aria-expanded", open ? "true" : "false"); if (open) { var sel = langMenu.querySelector("li.selected") || langMenu.querySelector("li[data-lang]"); if (sel) sel.focus(); } }; var chooseLang = function (v) { setMenuOpen(false); langToggleBtn.focus(); if (v) setLang(v); }; langToggleBtn.addEventListener("click", function (e) { e.stopPropagation(); setMenuOpen(langMenu.hidden); }); langToggleBtn.addEventListener("keydown", function (e) { if (e.key === "Escape" && !langMenu.hidden) { e.stopPropagation(); setMenuOpen(false); } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && langMenu.hidden) { e.preventDefault(); e.stopPropagation(); setMenuOpen(true); } }); langMenu.addEventListener("click", function (e) { e.stopPropagation(); var li = e.target && e.target.closest ? e.target.closest("li[data-lang]") : null; if (li) chooseLang(li.getAttribute("data-lang")); else setMenuOpen(false); }); langMenu.addEventListener("keydown", function (e) { e.stopPropagation(); // keep nudge / slide-nav shortcuts away while the menu is open var items = Array.prototype.slice.call(langMenu.querySelectorAll("li[data-lang]")); var idx = items.indexOf(document.activeElement); if (e.key === "Escape") { setMenuOpen(false); langToggleBtn.focus(); } else if (e.key === "ArrowDown") { e.preventDefault(); (items[idx + 1] || items[0]).focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); (items[idx - 1] || items[items.length - 1]).focus(); } else if (e.key === "Home") { e.preventDefault(); items[0].focus(); } else if (e.key === "End") { e.preventDefault(); items[items.length - 1].focus(); } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (idx >= 0) chooseLang(items[idx].getAttribute("data-lang")); } }); langToggleBtn.parentElement.addEventListener("focusout", function (e) { if (!langMenu.hidden && !langToggleBtn.parentElement.contains(e.relatedTarget)) setMenuOpen(false); }); document.addEventListener("click", function () { if (!langMenu.hidden) setMenuOpen(false); }); } loadConfig().then(function () { loadSlides(); startSlidePolling(); }); initRubberBand(); initKeyboardShortcuts(); initSlideNav(); if (btnUndo) btnUndo.addEventListener("click", runUndo); })();