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