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