3118 lines
140 KiB
JavaScript
3118 lines
140 KiB
JavaScript
/* ============================================================
|
||
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 <svg> 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が不正か、ルート<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 可能损坏或缺少根 <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 可能損壞或缺少根 <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 =
|
||
'<div>' + escapeHtml(t("pending_summary", {
|
||
edits: edits,
|
||
annotations: annotations,
|
||
})) + '</div>' +
|
||
(pages.length ? '<div class="pending-pages">' + escapeHtml(t("pending_pages", {
|
||
pages: pages.join(", "),
|
||
})) + '</div>' : '');
|
||
}
|
||
|
||
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 =
|
||
'<span class="el-tag"><' + escapeHtml(tag) + '></span>' +
|
||
'<span class="el-id">' + escapeHtml(eid) + '</span>';
|
||
propsEl.innerHTML = renderEditableProps(el);
|
||
attachPropEditors(el);
|
||
}
|
||
} else {
|
||
selectedElementEl.innerHTML =
|
||
'<span class="multi-count">' + escapeHtml(t("multi_selected", { count: count })) + '</span>';
|
||
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 <use data-icon> into a <g>, but disk still owns
|
||
// the source <use>. 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 = '<span class="overlap-tag"><' + escapeHtml(tag) +
|
||
'></span><span class="overlap-id">' + escapeHtml(el.id) + '</span>' +
|
||
(txt ? '<span class="overlap-txt">' + escapeHtml(txt) + '</span>' : '');
|
||
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 = '<div class="annotations-empty">' + escapeHtml(t("empty_annotations")) + '</div>';
|
||
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 = '<div class="prop-caption">' +
|
||
escapeHtml(isGroup ? t("label_group_edit") : t("label_direct_edit")) + '</div>';
|
||
|
||
var parentGroup = nearestParentGroup(el);
|
||
if (parentGroup && !isGroup) {
|
||
html += '<table class="prop-table"><tr><td class="prop-key">group</td><td class="prop-val">' +
|
||
'<button type="button" class="btn-select-group" data-group-id="' +
|
||
escapeHtml(parentGroup.id) + '">' + escapeHtml(t("btn_select_group")) +
|
||
'</button></td></tr></table>';
|
||
}
|
||
|
||
html += '<div class="prop-section">' + escapeHtml(t("section_geometry")) + '</div>';
|
||
html += '<table class="prop-table">';
|
||
// 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 += '<tr><td class="prop-key">position</td><td class="prop-val geo-cell">' +
|
||
'<input type="number" class="prop-edit prop-edit-geo" data-geo="x" value="' + Math.round(rb.x) + '">' +
|
||
'<input type="number" class="prop-edit prop-edit-geo" data-geo="y" value="' + Math.round(rb.y) + '">' +
|
||
'</td></tr>';
|
||
html += '<tr><td class="prop-key">size</td><td class="prop-val geo-cell">' +
|
||
'<input type="number" class="prop-edit prop-edit-geo" data-geo="w" min="1" value="' + Math.round(rb.w) + '">' +
|
||
'<input type="number" class="prop-edit prop-edit-geo" data-geo="h" min="1" value="' + Math.round(rb.h) + '">' +
|
||
'</td></tr>';
|
||
} catch (e) { /* no geometry */ }
|
||
html += '</table>';
|
||
|
||
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 += '<div class="prop-section">' + escapeHtml(t("section_text_style")) + '</div>';
|
||
html += '<table class="prop-table">';
|
||
if ((tag === "text" || tag === "tspan") && el.querySelector("tspan")) {
|
||
html += '<tr><td class="prop-key">content</td><td class="prop-val prop-note">' +
|
||
escapeHtml(t("prop_multiline_hint")) + '</td></tr>';
|
||
} else if (tag === "text" || tag === "tspan") {
|
||
html += '<tr><td class="prop-key">content</td><td class="prop-val">' +
|
||
'<textarea class="prop-edit prop-edit-content" rows="2">' +
|
||
escapeHtml(el.textContent || "") + '</textarea></td></tr>';
|
||
}
|
||
if (textStyleTargets.length > 0) {
|
||
textStyleSpecs(textStyleTargets).forEach(function (spec) {
|
||
html += '<tr><td class="prop-key">' + escapeHtml(spec.key) + '</td><td class="prop-val">';
|
||
html += renderTextStyleControl(spec, textStyleTargets);
|
||
html += '</td></tr>';
|
||
});
|
||
}
|
||
html += '</table>';
|
||
}
|
||
|
||
html += '<div class="prop-section">' + escapeHtml(t("section_raw_attrs")) + '</div>';
|
||
html += '<table class="prop-table">';
|
||
ownAttributeSpecs(el).forEach(function (spec) {
|
||
html += '<tr><td class="prop-key">' + escapeHtml(spec.key) + '</td><td class="prop-val">';
|
||
if (!spec.editable) {
|
||
html += '<span class="prop-ro">' + escapeHtml(spec.value) + '</span>';
|
||
} else {
|
||
html += renderAttributeControl(spec);
|
||
}
|
||
html += '</td></tr>';
|
||
});
|
||
|
||
html += '</table>';
|
||
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 '<input type="color" class="prop-edit prop-edit-text-style-color" data-key="' +
|
||
escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
|
||
'" value="' + normalizeHexForPicker(spec.value) + '">' +
|
||
'<input type="text" class="prop-edit prop-edit-text-style" data-key="' +
|
||
escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
|
||
'" placeholder="' + escapeHtml(placeholder) +
|
||
'" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
|
||
}
|
||
if (spec.type === "select") {
|
||
var html = '<select class="prop-edit prop-edit-text-style" data-key="' +
|
||
escapeHtml(spec.key) + '" data-target-ids="' + targetIds + '">';
|
||
var hasValue = spec.options.indexOf(spec.value) !== -1;
|
||
if (!spec.mixed && spec.value && !hasValue) {
|
||
html += '<option value="' + escapeHtml(spec.value) + '" selected>' +
|
||
escapeHtml(spec.value) + '</option>';
|
||
}
|
||
spec.options.forEach(function (opt) {
|
||
var label = opt || placeholder || "";
|
||
var selected = !spec.mixed && spec.value === opt ? " selected" : "";
|
||
html += '<option value="' + escapeHtml(opt) + '"' + selected + '>' +
|
||
escapeHtml(label) + '</option>';
|
||
});
|
||
html += '</select>';
|
||
return html;
|
||
}
|
||
return '<input type="text" class="prop-edit prop-edit-text-style" data-key="' +
|
||
escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
|
||
'" placeholder="' + escapeHtml(placeholder) +
|
||
'" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
|
||
}
|
||
|
||
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 '<input type="color" class="prop-edit prop-edit-color" data-key="' +
|
||
escapeHtml(spec.key) + '" value="' + normalizeHexForPicker(spec.value) + '">' +
|
||
'<input type="text" class="prop-edit prop-edit-color-text" data-key="' +
|
||
escapeHtml(spec.key) + '" value="' + escapeHtml(spec.value) + '">';
|
||
}
|
||
if (spec.value.length > 60 || spec.key === "d" || spec.key === "points" || spec.key === "style") {
|
||
return '<textarea class="prop-edit prop-edit-attr-area" rows="2" data-key="' +
|
||
escapeHtml(spec.key) + '">' + escapeHtml(spec.value) + '</textarea>';
|
||
}
|
||
return '<input type="text" class="prop-edit prop-edit-attr" data-key="' +
|
||
escapeHtml(spec.key) + '" value="' + escapeHtml(spec.value) + '">';
|
||
}
|
||
|
||
// ---- 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 <svg>.
|
||
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 = '<div class="prop-caption">' + escapeHtml(t("label_batch_edit")) + '</div>';
|
||
|
||
if (moveEls.length > 0) {
|
||
try {
|
||
var rb = computeElementsBox(moveEls);
|
||
summary += '<div class="prop-section">' + escapeHtml(t("section_geometry")) + '</div>';
|
||
summary += '<table class="prop-table"><tr><td class="prop-key">x / y</td>' +
|
||
'<td class="prop-val geo-cell">' +
|
||
'<input type="number" class="prop-edit prop-edit-multi-geo" data-geo="x" value="' +
|
||
Math.round(rb.x) + '">' +
|
||
'<input type="number" class="prop-edit prop-edit-multi-geo" data-geo="y" value="' +
|
||
Math.round(rb.y) + '">' +
|
||
'</td></tr></table>';
|
||
} catch (e) { /* no multi geometry */ }
|
||
}
|
||
summary += '<div class="prop-section">' + escapeHtml(t("section_style")) + '</div>';
|
||
summary += '<table class="prop-table">';
|
||
batchEditSpecs(moveEls).forEach(function (spec) {
|
||
summary += '<tr><td class="prop-key">' + escapeHtml(spec.key) + '</td><td class="prop-val">';
|
||
summary += renderBatchControl(spec);
|
||
summary += '</td></tr>';
|
||
});
|
||
summary += '</table>';
|
||
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 '<input type="color" class="prop-edit prop-edit-batch-color" data-key="' +
|
||
escapeHtml(spec.key) + '" value="' + normalizeHexForPicker(spec.value) + '">' +
|
||
'<input type="text" class="prop-edit prop-edit-batch" data-key="' +
|
||
escapeHtml(spec.key) + '" placeholder="' + escapeHtml(placeholder) +
|
||
'" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
|
||
}
|
||
if (spec.type === "number") {
|
||
return '<input type="number" class="prop-edit prop-edit-batch" data-key="' +
|
||
escapeHtml(spec.key) + '" min="' + escapeHtml(spec.min || "") +
|
||
'" max="' + escapeHtml(spec.max || "") + '" step="' +
|
||
escapeHtml(spec.step || "1") + '" placeholder="' + escapeHtml(placeholder) +
|
||
'" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
|
||
}
|
||
if (spec.type === "select") {
|
||
var html = '<select class="prop-edit prop-edit-batch" data-key="' + escapeHtml(spec.key) + '">';
|
||
spec.options.forEach(function (opt) {
|
||
var label = opt || placeholder || "";
|
||
var selected = !spec.mixed && spec.value === opt ? " selected" : "";
|
||
html += '<option value="' + escapeHtml(opt) + '"' + selected + '>' +
|
||
escapeHtml(label) + '</option>';
|
||
});
|
||
html += '</select>';
|
||
return html;
|
||
}
|
||
return '<input type="text" class="prop-edit prop-edit-batch" data-key="' +
|
||
escapeHtml(spec.key) + '" placeholder="' + escapeHtml(placeholder) +
|
||
'" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
|
||
}
|
||
|
||
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);
|
||
})();
|