synabun 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,3262 @@
1
+ // ═══════════════════════════════════════════
2
+ // UI-WHITEBOARD — Focus Mode whiteboard
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // A simple fixed-area whiteboard that activates inside Focus mode.
6
+ // Features: text cards (Caveat font), geometric shapes (rect, circle,
7
+ // rounded-rect), curvy SVG arrows with anchoring, image paste with
8
+ // rounded borders, undo/redo, and full workspace persistence.
9
+
10
+ import { state, emit, on } from './state.js';
11
+ import { KEYS } from './constants.js';
12
+ import { storage } from './storage.js';
13
+ import { isGuest, hasPermission } from './ui-sync.js';
14
+
15
+ // ── Helpers ──
16
+
17
+ const $ = (id) => document.getElementById(id);
18
+ const _genId = () => 'wb-' + Date.now() + '-' + Math.random().toString(36).slice(2, 5);
19
+
20
+ // ── Internal state ──
21
+
22
+ let _elements = []; // Array<{id, type, x, y, width, height, ...}>
23
+ let _nextZIndex = 1;
24
+ let _activeTool = 'select'; // 'select' | 'text' | 'arrow' | 'shape' | 'pen'
25
+ let _selectedId = null; // Primary selected element (for context menu, editing)
26
+ let _selectedIds = new Set(); // All selected element IDs (multi-select)
27
+ let _undoStack = []; // Array<{action, before, after}>
28
+ let _redoStack = [];
29
+ let _clipboard = null; // Serialized element for copy/paste
30
+
31
+ // Drag state
32
+ let _drag = null; // { type, id, startX, startY, origX, origY, ... }
33
+
34
+ // Marquee (rubber band) selection state
35
+ let _marquee = null; // { startX, startY, rect: DOMElement } while dragging
36
+ let _multiSelectMode = false; // Toggled via toolbar button
37
+
38
+ // Pen drawing state
39
+ let _penPoints = null; // Array<[x,y]> while drawing, null when idle
40
+ let _penLiveEl = null; // Temp SVG path for live preview
41
+ let _arrowCreating = null; // { points: [[x,y],...], startAnchor } — multi-point arrow in progress
42
+ let _sectionCreating = null; // { id, originX, originY, sectionType } — drag-to-create section
43
+ let _activeSectionType = 'content'; // default section type for new placements
44
+ let _shapeCreating = null; // { id, originX, originY } — drag-to-create shape
45
+ let _activeShapeType = 'rect'; // default shape type for new placements
46
+
47
+ // Tool lock (Ctrl-hold keeps tool active)
48
+ let _toolLocked = false;
49
+
50
+ // Active drawing color
51
+ let _activeColor = 'rgba(255,255,255,0.85)';
52
+ const WB_COLORS = [
53
+ 'rgba(255,255,255,0.85)', // white
54
+ '#ef4444', // red
55
+ '#f97316', // orange
56
+ '#eab308', // yellow
57
+ '#22c55e', // green
58
+ '#3b82f6', // blue
59
+ '#a855f7', // purple
60
+ '#ec4899', // pink
61
+ '#06b6d4', // cyan
62
+ 'rgba(255,255,255,0.3)', // dim
63
+ ];
64
+
65
+ const SECTION_TYPES = {
66
+ navbar: { label: 'Navbar', w: 960, h: 56, color: '#64748b', icon: '\u2261' },
67
+ hero: { label: 'Hero', w: 960, h: 340, color: '#6366f1', icon: '\u2606' },
68
+ sidebar: { label: 'Sidebar', w: 260, h: 400, color: '#475569', icon: '\u229e' },
69
+ content: { label: 'Content', w: 640, h: 360, color: '#737373', icon: '\u00b6' },
70
+ footer: { label: 'Footer', w: 960, h: 100, color: '#6b7280', icon: '\u2500' },
71
+ card: { label: 'Card', w: 260, h: 180, color: '#14b8a6', icon: '\u25a1' },
72
+ form: { label: 'Form', w: 380, h: 280, color: '#f59e0b', icon: '\u2610' },
73
+ 'image-placeholder': { label: 'Image', w: 280, h: 180, color: '#a855f7', icon: '\u229e' },
74
+ button: { label: 'Button', w: 140, h: 42, color: '#22c55e', icon: '\u25b8' },
75
+ 'text-block': { label: 'Text Block', w: 380, h: 90, color: '#e2e8f0', icon: 'T' },
76
+ grid: { label: 'Grid', w: 640, h: 320, color: '#06b6d4', icon: '\u229e\u229e' },
77
+ modal: { label: 'Modal', w: 440, h: 300, color: '#f43f5e', icon: '\u25fb' },
78
+ };
79
+
80
+ // Persist debounce + cross-client sync
81
+ let _persistTimer = null;
82
+ let _isRemoteUpdate = false; // Suppress WS echo when receiving remote state
83
+ const PERSIST_DELAY = 600;
84
+ const MAX_UNDO = 50;
85
+ const SNAP_DIST = 30; // px for arrow anchor snapping
86
+ const MAX_IMAGE_DIM = 1920;
87
+
88
+ /** Cross-browser clipboard write with fallback for non-secure contexts. */
89
+ function _clipboardWrite(text) {
90
+ if (navigator.clipboard?.writeText) {
91
+ navigator.clipboard.writeText(text).catch(() => _clipboardFallback(text));
92
+ } else {
93
+ _clipboardFallback(text);
94
+ }
95
+ }
96
+ function _clipboardFallback(text) {
97
+ const ta = document.createElement('textarea');
98
+ ta.value = text;
99
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0';
100
+ document.body.appendChild(ta);
101
+ ta.select();
102
+ try { document.execCommand('copy'); } catch {}
103
+ ta.remove();
104
+ }
105
+
106
+ /** Snap a value to grid if grid snapping is enabled. */
107
+ function _snap(v) {
108
+ if (!state.gridSnap) return v;
109
+ const gs = state.gridSize || 20;
110
+ return Math.round(v / gs) * gs;
111
+ }
112
+ const IMAGE_QUALITY = 0.8;
113
+
114
+ // DOM refs (set in init)
115
+ let _root, _toolbar, _canvas, _arrowsSvg, _elementsDiv, _arrowPreview, _arrowHint;
116
+
117
+
118
+ // ═══════════════════════════════════════════
119
+ // COORDINATE TRANSFORMS
120
+ // ═══════════════════════════════════════════
121
+
122
+ function clientToCanvas(clientX, clientY) {
123
+ const rect = _root.getBoundingClientRect();
124
+ return {
125
+ x: clientX - rect.left,
126
+ y: clientY - rect.top,
127
+ };
128
+ }
129
+
130
+
131
+ // ═══════════════════════════════════════════
132
+ // UNDO / REDO
133
+ // ═══════════════════════════════════════════
134
+
135
+ function pushUndo(action, before, after) {
136
+ _undoStack.push({ action, before, after });
137
+ if (_undoStack.length > MAX_UNDO) _undoStack.shift();
138
+ _redoStack = [];
139
+ updateUndoButtons();
140
+ }
141
+
142
+ function undo() {
143
+ if (!_undoStack.length) return;
144
+ const entry = _undoStack.pop();
145
+ _redoStack.push(entry);
146
+
147
+ if (entry.action === 'add') {
148
+ _elements = _elements.filter(el => el.id !== entry.after.id);
149
+ } else if (entry.action === 'delete') {
150
+ _elements.push({ ...entry.before });
151
+ } else if (entry.action === 'delete-multi') {
152
+ // Restore all deleted elements
153
+ for (const el of entry.before) _elements.push({ ...el });
154
+ } else if (entry.action === 'move-multi') {
155
+ // Restore all elements to their original positions
156
+ for (const snap of entry.before) {
157
+ const idx = _elements.findIndex(el => el.id === snap.id);
158
+ if (idx >= 0) _elements[idx] = { ...snap, points: snap.points ? snap.points.map(p => [...p]) : undefined };
159
+ }
160
+ } else if (entry.action === 'move' || entry.action === 'resize' || entry.action === 'edit') {
161
+ const idx = _elements.findIndex(el => el.id === entry.before.id);
162
+ if (idx >= 0) _elements[idx] = { ...entry.before };
163
+ }
164
+
165
+ _selectedId = null;
166
+ _selectedIds.clear();
167
+ renderAll();
168
+ persistDebounced();
169
+ updateUndoButtons();
170
+ }
171
+
172
+ function redo() {
173
+ if (!_redoStack.length) return;
174
+ const entry = _redoStack.pop();
175
+ _undoStack.push(entry);
176
+
177
+ if (entry.action === 'add') {
178
+ _elements.push({ ...entry.after });
179
+ } else if (entry.action === 'delete') {
180
+ _elements = _elements.filter(el => el.id !== entry.before.id);
181
+ } else if (entry.action === 'delete-multi') {
182
+ // Re-delete all elements
183
+ const ids = new Set(entry.before.map(el => el.id));
184
+ _elements = _elements.filter(el => !ids.has(el.id));
185
+ } else if (entry.action === 'move-multi') {
186
+ // Apply the after positions
187
+ for (const snap of entry.after) {
188
+ const idx = _elements.findIndex(el => el.id === snap.id);
189
+ if (idx >= 0) _elements[idx] = { ...snap, points: snap.points ? snap.points.map(p => [...p]) : undefined };
190
+ else _elements.push({ ...snap });
191
+ }
192
+ } else if (entry.action === 'move' || entry.action === 'resize' || entry.action === 'edit') {
193
+ const idx = _elements.findIndex(el => el.id === entry.after.id);
194
+ if (idx >= 0) _elements[idx] = { ...entry.after };
195
+ else _elements.push({ ...entry.after });
196
+ }
197
+
198
+ _selectedId = null;
199
+ _selectedIds.clear();
200
+ renderAll();
201
+ persistDebounced();
202
+ updateUndoButtons();
203
+ }
204
+
205
+ function updateUndoButtons() {
206
+ const undoBtn = $('wb-undo');
207
+ const redoBtn = $('wb-redo');
208
+ if (undoBtn) undoBtn.classList.toggle('disabled', !_undoStack.length);
209
+ if (redoBtn) redoBtn.classList.toggle('disabled', !_redoStack.length);
210
+ }
211
+
212
+
213
+ // ═══════════════════════════════════════════
214
+ // PERSISTENCE
215
+ // ═══════════════════════════════════════════
216
+
217
+ /** Flush in-progress list edits from contenteditable DOM back into _elements (without exiting edit mode). */
218
+ function flushListEdits() {
219
+ const dom = _root?.querySelector('.wb-list.editing');
220
+ if (!dom) return;
221
+ const ul = dom.querySelector('ul');
222
+ const elId = dom.dataset.wbId;
223
+ const el = _elements.find(e => e.id === elId);
224
+ if (el && ul) {
225
+ el.items = [...ul.querySelectorAll('li')].map(li => li.innerText.trimEnd());
226
+ if (!el.items.length) el.items = [''];
227
+ }
228
+ }
229
+
230
+ /** Flush in-progress text edits from contenteditable DOM back into _elements (without exiting edit mode). */
231
+ function flushTextEdits() {
232
+ const editingText = _root?.querySelector('.wb-text.editing .wb-text-content');
233
+ if (!editingText) return;
234
+ const dom = editingText.closest('[data-wb-id]');
235
+ if (dom) {
236
+ const el = _elements.find(e => e.id === dom.dataset.wbId);
237
+ if (el) el.content = editingText.textContent || '';
238
+ }
239
+ }
240
+
241
+ function persistDebounced() {
242
+ clearTimeout(_persistTimer);
243
+ _persistTimer = setTimeout(() => {
244
+ flushTextEdits();
245
+ flushListEdits();
246
+ const snapshot = { elements: _elements, nextZIndex: _nextZIndex };
247
+ storage.setItem(KEYS.WHITEBOARD, JSON.stringify(snapshot));
248
+ // Send to server for cross-client sync (skip if remote update or guest without whiteboard perm)
249
+ if (_ws && _ws.readyState === 1 && !_isRemoteUpdate && !(isGuest() && !hasPermission('whiteboard'))) {
250
+ _ws.send(JSON.stringify({ type: 'state:sync', snapshot }));
251
+ }
252
+ }, PERSIST_DELAY);
253
+ }
254
+
255
+ function loadPersisted() {
256
+ try {
257
+ const raw = storage.getItem(KEYS.WHITEBOARD);
258
+ if (!raw) return;
259
+ const data = JSON.parse(raw);
260
+ if (Array.isArray(data.elements)) _elements = data.elements;
261
+ if (data.nextZIndex) _nextZIndex = data.nextZIndex;
262
+ } catch { /* ignore corrupt data */ }
263
+ }
264
+
265
+
266
+ // ═══════════════════════════════════════════
267
+ // ARROW MATH
268
+ // ═══════════════════════════════════════════
269
+
270
+ function computeArrowPath(pts) {
271
+ if (!pts || pts.length < 2) return '';
272
+ return pointsToSmoothPath(pts);
273
+ }
274
+
275
+ /** Shorten a points array by pulling the last point back toward the previous one. */
276
+ function shortenPoints(pts, amount) {
277
+ if (pts.length < 2) return pts;
278
+ const result = pts.map(p => [...p]);
279
+ const last = result[result.length - 1];
280
+ const prev = result[result.length - 2];
281
+ const dx = last[0] - prev[0];
282
+ const dy = last[1] - prev[1];
283
+ const dist = Math.sqrt(dx * dx + dy * dy);
284
+ if (dist < amount * 2) return result;
285
+ const ratio = (dist - amount) / dist;
286
+ result[result.length - 1] = [
287
+ prev[0] + dx * ratio,
288
+ prev[1] + dy * ratio,
289
+ ];
290
+ return result;
291
+ }
292
+
293
+ /** Get arrow endpoint from an anchored element. Returns edge intersection point. */
294
+ function getAnchorPoint(anchorId, fromX, fromY) {
295
+ const el = _elements.find(e => e.id === anchorId);
296
+ if (!el || el.type === 'arrow') return null;
297
+ const cx = el.x + (el.width || 100) / 2;
298
+ const cy = el.y + (el.height || 60) / 2;
299
+ const hw = (el.width || 100) / 2;
300
+ const hh = (el.height || 60) / 2;
301
+
302
+ const dx = fromX - cx;
303
+ const dy = fromY - cy;
304
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return { x: cx, y: cy - hh };
305
+
306
+ const absDx = Math.abs(dx);
307
+ const absDy = Math.abs(dy);
308
+ let scale;
309
+ if (absDx / hw > absDy / hh) {
310
+ scale = hw / absDx;
311
+ } else {
312
+ scale = hh / absDy;
313
+ }
314
+ return { x: cx + dx * scale, y: cy + dy * scale };
315
+ }
316
+
317
+ /** Find the nearest element edge center within SNAP_DIST of (cx, cy). */
318
+ function findNearestAnchor(cx, cy, excludeId) {
319
+ let best = null;
320
+ let bestDist = SNAP_DIST;
321
+ for (const el of _elements) {
322
+ if (el.type === 'arrow' || el.id === excludeId) continue;
323
+ const ecx = el.x + (el.width || 100) / 2;
324
+ const ecy = el.y + (el.height || 60) / 2;
325
+ const d = Math.sqrt((cx - ecx) ** 2 + (cy - ecy) ** 2);
326
+ if (d < bestDist) {
327
+ bestDist = d;
328
+ best = el.id;
329
+ }
330
+ }
331
+ return best;
332
+ }
333
+
334
+
335
+ // ═══════════════════════════════════════════
336
+ // IMAGE COMPRESSION
337
+ // ═══════════════════════════════════════════
338
+
339
+ function compressImage(blob) {
340
+ return new Promise((resolve, reject) => {
341
+ const img = new Image();
342
+ const url = URL.createObjectURL(blob);
343
+ img.onload = () => {
344
+ URL.revokeObjectURL(url);
345
+ let w = img.width;
346
+ let h = img.height;
347
+ if (w > MAX_IMAGE_DIM || h > MAX_IMAGE_DIM) {
348
+ const ratio = Math.min(MAX_IMAGE_DIM / w, MAX_IMAGE_DIM / h);
349
+ w = Math.round(w * ratio);
350
+ h = Math.round(h * ratio);
351
+ }
352
+ const c = document.createElement('canvas');
353
+ c.width = w;
354
+ c.height = h;
355
+ const ctx = c.getContext('2d');
356
+ ctx.drawImage(img, 0, 0, w, h);
357
+ resolve({ dataUrl: c.toDataURL('image/jpeg', IMAGE_QUALITY), width: w, height: h });
358
+ };
359
+ img.onerror = reject;
360
+ img.src = url;
361
+ });
362
+ }
363
+
364
+
365
+ // ═══════════════════════════════════════════
366
+ // RENDERING
367
+ // ═══════════════════════════════════════════
368
+
369
+ function renderAll() {
370
+ renderElements();
371
+ renderArrows();
372
+ // Reposition context menu if element(s) selected
373
+ if (_ctxMenu && _selectedIds.size > 0) {
374
+ if (_selectedIds.size === 1) {
375
+ const el = _elements.find(e => e.id === _selectedId);
376
+ if (el) positionContextMenu(el);
377
+ } else {
378
+ positionContextMenuMulti();
379
+ }
380
+ }
381
+ }
382
+
383
+ function renderElements() {
384
+ if (!_elementsDiv) return;
385
+ const existing = new Map();
386
+ for (const child of [..._elementsDiv.children]) {
387
+ existing.set(child.dataset.wbId, child);
388
+ }
389
+
390
+ const activeIds = new Set();
391
+ for (const el of _elements) {
392
+ if (el.type === 'arrow' || el.type === 'pen') continue;
393
+ activeIds.add(el.id);
394
+ let dom = existing.get(el.id);
395
+ if (!dom) {
396
+ dom = createElementDOM(el);
397
+ _elementsDiv.appendChild(dom);
398
+ }
399
+ updateElementDOM(dom, el);
400
+ }
401
+
402
+ // Remove orphans
403
+ for (const [id, dom] of existing) {
404
+ if (!activeIds.has(id)) dom.remove();
405
+ }
406
+ }
407
+
408
+ function createElementDOM(el) {
409
+ const div = document.createElement('div');
410
+ div.dataset.wbId = el.id;
411
+
412
+ if (el.type === 'text') {
413
+ div.className = 'wb-text';
414
+ div.innerHTML = `<div class="wb-text-content" contenteditable="false"></div><div class="wb-resize-handle"></div>`;
415
+ } else if (el.type === 'list') {
416
+ div.className = 'wb-list';
417
+ div.innerHTML = `<ul></ul><div class="wb-resize-handle"></div>`;
418
+ } else if (el.type === 'shape') {
419
+ div.className = 'wb-shape';
420
+ const ns = 'http://www.w3.org/2000/svg';
421
+ const svg = document.createElementNS(ns, 'svg');
422
+ svg.classList.add('wb-shape-border');
423
+ div.appendChild(svg);
424
+ const handle = document.createElement('div');
425
+ handle.className = 'wb-resize-handle';
426
+ div.appendChild(handle);
427
+ } else if (el.type === 'image') {
428
+ div.className = 'wb-image';
429
+ div.innerHTML = `<img src="" alt=""><span class="wb-copy-path" data-tooltip="Copy image path"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><div class="wb-resize-handle"></div>`;
430
+ // Wire click-to-copy-path
431
+ const copyBtn = div.querySelector('.wb-copy-path');
432
+ copyBtn.addEventListener('click', (e) => {
433
+ e.stopPropagation();
434
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) {
435
+ copyBtn.dataset.tooltip = 'Not connected';
436
+ setTimeout(() => { copyBtn.dataset.tooltip = 'Copy image path'; }, 1500);
437
+ return;
438
+ }
439
+ if (!el.dataUrl) return;
440
+ const match = el.dataUrl.match(/^data:(image\/\w+);base64,(.+)$/);
441
+ if (!match) return;
442
+ const handler = (evt) => {
443
+ try {
444
+ const msg = JSON.parse(evt.data);
445
+ if (msg.type === 'image_saved' && msg.path) {
446
+ _ws.removeEventListener('message', handler);
447
+ _clipboardWrite(msg.path);
448
+ copyBtn.dataset.tooltip = 'Copied!';
449
+ copyBtn.classList.add('wb-copied');
450
+ setTimeout(() => {
451
+ copyBtn.dataset.tooltip = 'Copy image path';
452
+ copyBtn.classList.remove('wb-copied');
453
+ }, 1500);
454
+ }
455
+ } catch {}
456
+ };
457
+ _ws.addEventListener('message', handler);
458
+ setTimeout(() => _ws.removeEventListener('message', handler), 5000);
459
+ _ws.send(JSON.stringify({ type: 'image_save', data: match[2], mimeType: match[1] }));
460
+ });
461
+ } else if (el.type === 'section') {
462
+ div.className = 'wb-section';
463
+ div.innerHTML = `<div class="wb-section-type-icon"></div><div class="wb-section-label" contenteditable="false"></div><div class="wb-resize-handle"></div>`;
464
+ }
465
+ return div;
466
+ }
467
+
468
+ function updateElementDOM(dom, el) {
469
+ dom.style.left = el.x + 'px';
470
+ dom.style.top = el.y + 'px';
471
+ // Text and list cards auto-size from content; other elements use explicit dimensions
472
+ if (el.type === 'text' || el.type === 'list') {
473
+ dom.style.width = '';
474
+ dom.style.height = 'auto';
475
+ } else {
476
+ if (el.width) dom.style.width = el.width + 'px';
477
+ if (el.height) dom.style.height = el.height + 'px';
478
+ }
479
+ dom.style.zIndex = el.zIndex || 0;
480
+ dom.style.transform = el.rotation ? `rotate(${el.rotation}deg)` : '';
481
+ dom.classList.toggle('selected', _selectedIds.has(el.id));
482
+
483
+ if (el.type === 'text') {
484
+ const content = dom.querySelector('.wb-text-content');
485
+ if (content && !content.matches(':focus')) {
486
+ content.innerText = el.content || '';
487
+ }
488
+ if (el.fontSize) dom.style.fontSize = el.fontSize + 'px';
489
+ dom.style.fontWeight = el.bold ? '700' : '400';
490
+ dom.style.fontStyle = el.italic ? 'italic' : 'normal';
491
+ if (el.color) dom.style.color = el.color;
492
+ } else if (el.type === 'list') {
493
+ const ul = dom.querySelector('ul');
494
+ if (ul && !dom.classList.contains('editing')) {
495
+ const items = el.items && el.items.length ? el.items : [''];
496
+ ul.innerHTML = items.map(item =>
497
+ `<li>${item.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</li>`
498
+ ).join('');
499
+ }
500
+ if (el.fontSize) dom.style.fontSize = el.fontSize + 'px';
501
+ if (el.color) dom.style.color = el.color;
502
+ } else if (el.type === 'shape') {
503
+ const shape = el.shape || 'rect';
504
+ dom.dataset.shape = shape;
505
+ dom.style.borderRadius = '0';
506
+ const svg = dom.querySelector('.wb-shape-border');
507
+ if (svg) {
508
+ const w = el.width || 160, h = el.height || 100;
509
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
510
+ svg.setAttribute('width', w);
511
+ svg.setAttribute('height', h);
512
+ const sc = el.color || 'rgba(255,255,255,0.8)';
513
+ if (shape === 'drawn-circle') {
514
+ svg.innerHTML = `<path d="${generateDrawnCirclePath(w, h, el.id)}" fill="none" stroke="${sc}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`;
515
+ } else {
516
+ svg.innerHTML = `<path d="${generateHandDrawnShape(shape, w, h, el.id)}" fill="none" stroke="${sc}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`;
517
+ }
518
+ }
519
+ } else if (el.type === 'image') {
520
+ const img = dom.querySelector('img');
521
+ if (img && img.src !== el.dataUrl) img.src = el.dataUrl;
522
+ } else if (el.type === 'section') {
523
+ const def = SECTION_TYPES[el.sectionType] || SECTION_TYPES.content;
524
+ const color = el.color || def.color;
525
+ dom.style.borderColor = color;
526
+ dom.style.background = color + '0d';
527
+ const iconEl = dom.querySelector('.wb-section-type-icon');
528
+ if (iconEl) {
529
+ iconEl.textContent = def.icon;
530
+ iconEl.style.color = color;
531
+ }
532
+ const labelEl = dom.querySelector('.wb-section-label');
533
+ if (labelEl && !labelEl.matches(':focus')) {
534
+ labelEl.textContent = el.label || def.label;
535
+ labelEl.style.color = color;
536
+ }
537
+ }
538
+ }
539
+
540
+ function renderArrows() {
541
+ if (!_arrowsSvg) return;
542
+ const defs = _arrowsSvg.querySelector('defs');
543
+ _arrowsSvg.innerHTML = '';
544
+ if (defs) _arrowsSvg.appendChild(defs);
545
+
546
+ for (const el of _elements) {
547
+ // ── Arrows ──
548
+ if (el.type === 'arrow') {
549
+ const pts = (el.points || []).map(p => [...p]);
550
+ if (pts.length < 2) continue;
551
+
552
+ // Apply anchor offsets to first/last points
553
+ if (el.startAnchor) {
554
+ const pt = getAnchorPoint(el.startAnchor, pts[1][0], pts[1][1]);
555
+ if (pt) { pts[0] = [pt.x, pt.y]; }
556
+ }
557
+ if (el.endAnchor) {
558
+ const pt = getAnchorPoint(el.endAnchor, pts[pts.length - 2][0], pts[pts.length - 2][1]);
559
+ if (pt) { pts[pts.length - 1] = [pt.x, pt.y]; }
560
+ }
561
+
562
+ const isSel = _selectedIds.has(el.id);
563
+ const d = computeArrowPath(pts);
564
+
565
+ // Invisible wide hit target (20px)
566
+ const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
567
+ hit.setAttribute('d', d);
568
+ hit.setAttribute('fill', 'none');
569
+ hit.setAttribute('stroke', 'transparent');
570
+ hit.setAttribute('stroke-width', '20');
571
+ hit.dataset.wbId = el.id;
572
+ _arrowsSvg.appendChild(hit);
573
+
574
+ // Visible arrow with marker — butt linecap so stroke ends flush at endpoint
575
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
576
+ path.setAttribute('d', d);
577
+ path.setAttribute('fill', 'none');
578
+ path.setAttribute('stroke', isSel ? 'var(--accent-blue, #60a5fa)' : 'rgba(255,255,255,0.8)');
579
+ path.setAttribute('stroke-width', isSel ? '3' : '2');
580
+ path.setAttribute('stroke-linecap', 'butt');
581
+ path.setAttribute('stroke-linejoin', 'round');
582
+ path.setAttribute('marker-end', isSel ? 'url(#wb-arrowhead-sel)' : 'url(#wb-arrowhead)');
583
+ path.dataset.wbId = el.id;
584
+ path.style.pointerEvents = 'none';
585
+ if (isSel) path.classList.add('selected');
586
+ _arrowsSvg.appendChild(path);
587
+ continue;
588
+ }
589
+
590
+ // ── Pen strokes ──
591
+ if (el.type === 'pen') {
592
+ const isSel = _selectedIds.has(el.id);
593
+ const pathD = el.pathD || pointsToSmoothPath(el.points || []);
594
+
595
+ // Invisible wide hit target (16px)
596
+ const penHit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
597
+ penHit.setAttribute('d', pathD);
598
+ penHit.setAttribute('fill', 'none');
599
+ penHit.setAttribute('stroke', 'transparent');
600
+ penHit.setAttribute('stroke-width', '16');
601
+ penHit.dataset.wbId = el.id;
602
+ _arrowsSvg.appendChild(penHit);
603
+
604
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
605
+ path.setAttribute('d', pathD);
606
+ path.setAttribute('fill', 'none');
607
+ path.setAttribute('stroke', isSel ? 'var(--accent-blue, #60a5fa)' : (el.color || 'rgba(255,255,255,0.8)'));
608
+ path.setAttribute('stroke-width', isSel ? '5' : (el.strokeWidth || 3));
609
+ path.setAttribute('stroke-linecap', 'round');
610
+ path.setAttribute('stroke-linejoin', 'round');
611
+ path.dataset.wbId = el.id;
612
+ path.style.pointerEvents = 'none';
613
+ if (isSel) path.classList.add('selected');
614
+ _arrowsSvg.appendChild(path);
615
+ }
616
+ }
617
+ }
618
+
619
+
620
+ // ═══════════════════════════════════════════
621
+ // ELEMENT CRUD
622
+ // ═══════════════════════════════════════════
623
+
624
+ function addElement(el, { skipPersist = false } = {}) {
625
+ el.zIndex = _nextZIndex++;
626
+ _elements.push(el);
627
+ pushUndo('add', null, { ...el });
628
+ renderAll();
629
+ if (!skipPersist) persistDebounced();
630
+ return el;
631
+ }
632
+
633
+ function deleteElement(id) {
634
+ const idx = _elements.findIndex(e => e.id === id);
635
+ if (idx < 0) return;
636
+ const el = { ..._elements[idx] };
637
+ _elements.splice(idx, 1);
638
+
639
+ // Detach arrows anchored to deleted element
640
+ const cx = el.x + (el.width || 100) / 2;
641
+ const cy = el.y + (el.height || 60) / 2;
642
+ for (const a of _elements) {
643
+ if (a.type !== 'arrow' || !a.points) continue;
644
+ if (a.startAnchor === id) {
645
+ a.points[0] = [cx, cy]; a.startAnchor = null;
646
+ }
647
+ if (a.endAnchor === id) {
648
+ a.points[a.points.length - 1] = [cx, cy]; a.endAnchor = null;
649
+ }
650
+ }
651
+
652
+ pushUndo('delete', el, null);
653
+ _selectedIds.delete(id);
654
+ if (_selectedId === id) _selectedId = null;
655
+ hideContextMenu();
656
+ renderAll();
657
+ persistDebounced();
658
+ }
659
+
660
+
661
+ function deleteMultipleElements(ids) {
662
+ const deleted = [];
663
+ for (const id of ids) {
664
+ const idx = _elements.findIndex(e => e.id === id);
665
+ if (idx < 0) continue;
666
+ const el = { ..._elements[idx] };
667
+ _elements.splice(idx, 1);
668
+ // Detach arrows anchored to this element
669
+ const cx = el.x + (el.width || 100) / 2;
670
+ const cy = el.y + (el.height || 60) / 2;
671
+ for (const a of _elements) {
672
+ if (a.type !== 'arrow' || !a.points) continue;
673
+ if (a.startAnchor === id) { a.points[0] = [cx, cy]; a.startAnchor = null; }
674
+ if (a.endAnchor === id) { a.points[a.points.length - 1] = [cx, cy]; a.endAnchor = null; }
675
+ }
676
+ deleted.push(el);
677
+ }
678
+ if (deleted.length) {
679
+ pushUndo('delete-multi', deleted, null);
680
+ _selectedId = null;
681
+ _selectedIds.clear();
682
+ hideContextMenu();
683
+ renderAll();
684
+ persistDebounced();
685
+ }
686
+ }
687
+
688
+ function duplicateMultipleElements(ids) {
689
+ const newIds = [];
690
+ for (const id of ids) {
691
+ const src = _elements.find(e => e.id === id);
692
+ if (!src) continue;
693
+ const clone = { ...src, id: _genId() };
694
+ if (clone.x != null) clone.x += 20;
695
+ if (clone.y != null) clone.y += 20;
696
+ if (clone.points) clone.points = clone.points.map(([px, py]) => [px + 20, py + 20]);
697
+ delete clone.zIndex;
698
+ clone.startAnchor = null;
699
+ clone.endAnchor = null;
700
+ if (clone.points) clone.pathD = pointsToSmoothPath(clone.points);
701
+ clone.zIndex = _nextZIndex++;
702
+ _elements.push(clone);
703
+ pushUndo('add', null, { ...clone });
704
+ newIds.push(clone.id);
705
+ }
706
+ selectMultiple(newIds);
707
+ persistDebounced();
708
+ }
709
+
710
+
711
+ // ═══════════════════════════════════════════
712
+ // SELECTION + CONTEXT MENU
713
+ // ═══════════════════════════════════════════
714
+
715
+ let _ctxMenu = null; // Floating context menu element
716
+ let _rotateEl = null; // Floating rotation handle element
717
+ let _moveEl = null; // Floating move handle element (text/list)
718
+
719
+ function selectElement(id, { additive = false } = {}) {
720
+ if (additive) {
721
+ // Shift-click: toggle in/out of selection
722
+ if (_selectedIds.has(id)) {
723
+ _selectedIds.delete(id);
724
+ if (_selectedId === id) _selectedId = _selectedIds.size ? [..._selectedIds][0] : null;
725
+ } else {
726
+ _selectedIds.add(id);
727
+ _selectedId = id;
728
+ const el = _elements.find(e => e.id === id);
729
+ if (el && el.type !== 'arrow' && el.type !== 'pen') el.zIndex = _nextZIndex++;
730
+ }
731
+ } else {
732
+ // Normal click: single select (replaces selection)
733
+ _selectedIds.clear();
734
+ _selectedIds.add(id);
735
+ _selectedId = id;
736
+ const el = _elements.find(e => e.id === id);
737
+ if (el && el.type !== 'arrow' && el.type !== 'pen') el.zIndex = _nextZIndex++;
738
+ }
739
+ renderAll();
740
+ if (_selectedIds.size === 1) {
741
+ showContextMenu(id);
742
+ } else if (_selectedIds.size > 1) {
743
+ showMultiContextMenu();
744
+ } else {
745
+ hideContextMenu();
746
+ }
747
+ }
748
+
749
+ function selectMultiple(ids) {
750
+ _selectedIds = new Set(ids);
751
+ _selectedId = ids.length ? ids[0] : null;
752
+ renderAll();
753
+ if (_selectedIds.size === 1) {
754
+ showContextMenu(_selectedId);
755
+ } else if (_selectedIds.size > 1) {
756
+ showMultiContextMenu();
757
+ } else {
758
+ hideContextMenu();
759
+ }
760
+ }
761
+
762
+ function deselectAll() {
763
+ hideContextMenu();
764
+ if (_selectedIds.size > 0 || _selectedId) {
765
+ _selectedId = null;
766
+ _selectedIds.clear();
767
+ renderAll();
768
+ }
769
+ }
770
+
771
+ function showContextMenu(id) {
772
+ hideContextMenu();
773
+ const el = _elements.find(e => e.id === id);
774
+ if (!el || !_root) return;
775
+
776
+ _ctxMenu = document.createElement('div');
777
+ _ctxMenu.className = 'wb-ctx-menu';
778
+
779
+ // Text formatting controls (text and list elements)
780
+ const textControls = (el.type === 'text' || el.type === 'list') ? `
781
+ <div class="wb-ctx-divider"></div>
782
+ <button class="wb-ctx-btn" data-action="font-down" data-tooltip="Smaller"><svg viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
783
+ <span class="wb-ctx-size">${el.fontSize || (el.type === 'list' ? 18 : 22)}</span>
784
+ <button class="wb-ctx-btn" data-action="font-up" data-tooltip="Bigger"><svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
785
+ ${el.type === 'text' ? `
786
+ <div class="wb-ctx-divider"></div>
787
+ <button class="wb-ctx-btn ${el.bold ? 'wb-ctx-active' : ''}" data-action="bold" data-tooltip="Bold"><svg viewBox="0 0 24 24"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg></button>
788
+ <button class="wb-ctx-btn ${el.italic ? 'wb-ctx-active' : ''}" data-action="italic" data-tooltip="Italic"><svg viewBox="0 0 24 24"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg></button>` : ''}
789
+ <div class="wb-ctx-divider"></div>
790
+ ` : '';
791
+
792
+ // "Send to" buttons for image elements
793
+ const sendControls = el.type === 'image' ? `
794
+ <button class="wb-ctx-btn" data-action="send-terminal" data-tooltip="Send to Terminal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg></button>
795
+ <button class="wb-ctx-btn" data-action="send-panel" data-tooltip="Send to Panel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
796
+ <div class="wb-ctx-divider"></div>
797
+ ` : '';
798
+
799
+ _ctxMenu.innerHTML = `
800
+ ${textControls}
801
+ ${sendControls}
802
+ <button class="wb-ctx-btn" data-action="duplicate" data-tooltip="Duplicate"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
803
+ <button class="wb-ctx-btn" data-action="copy" data-tooltip="Copy"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
804
+ <button class="wb-ctx-btn wb-ctx-danger" data-action="delete" data-tooltip="Delete"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
805
+ `;
806
+
807
+ _ctxMenu.addEventListener('click', (ev) => {
808
+ const btn = ev.target.closest('[data-action]');
809
+ if (!btn) return;
810
+ const action = btn.dataset.action;
811
+ if (action === 'delete') {
812
+ deleteElement(id);
813
+ } else if (action === 'copy') {
814
+ const src = _elements.find(el2 => el2.id === id);
815
+ if (src) _clipboard = { ...src };
816
+ } else if (action === 'font-up' || action === 'font-down') {
817
+ const src = _elements.find(el2 => el2.id === id);
818
+ if (src) {
819
+ const before = { ...src };
820
+ const step = action === 'font-up' ? 2 : -2;
821
+ const defaultFs = src.type === 'list' ? 18 : 22;
822
+ src.fontSize = Math.max(10, Math.min(72, (src.fontSize || defaultFs) + step));
823
+ pushUndo('edit', before, { ...src });
824
+ renderAll();
825
+ persistDebounced();
826
+ showContextMenu(id); // refresh to update size label
827
+ }
828
+ } else if (action === 'bold') {
829
+ const src = _elements.find(el2 => el2.id === id);
830
+ if (src) {
831
+ const before = { ...src };
832
+ src.bold = !src.bold;
833
+ pushUndo('edit', before, { ...src });
834
+ renderAll();
835
+ persistDebounced();
836
+ showContextMenu(id);
837
+ }
838
+ } else if (action === 'italic') {
839
+ const src = _elements.find(el2 => el2.id === id);
840
+ if (src) {
841
+ const before = { ...src };
842
+ src.italic = !src.italic;
843
+ pushUndo('edit', before, { ...src });
844
+ renderAll();
845
+ persistDebounced();
846
+ showContextMenu(id);
847
+ }
848
+ } else if (action === 'send-terminal') {
849
+ const src = _elements.find(el2 => el2.id === id);
850
+ if (src?.type === 'image' && src.dataUrl) {
851
+ emit('wb:send-to-terminal', { dataUrl: src.dataUrl });
852
+ }
853
+ } else if (action === 'send-panel') {
854
+ const src = _elements.find(el2 => el2.id === id);
855
+ if (src?.type === 'image' && src.dataUrl) {
856
+ emit('wb:send-to-panel', { dataUrl: src.dataUrl });
857
+ }
858
+ } else if (action === 'duplicate') {
859
+ const src = _elements.find(el2 => el2.id === id);
860
+ if (src) {
861
+ const clone = { ...src, id: _genId() };
862
+ if (clone.x != null) clone.x += 20;
863
+ if (clone.y != null) clone.y += 20;
864
+ if (clone.points) clone.points = clone.points.map(([px, py]) => [px + 20, py + 20]);
865
+ delete clone.zIndex;
866
+ clone.startAnchor = null;
867
+ clone.endAnchor = null;
868
+ if (clone.points) clone.pathD = pointsToSmoothPath(clone.points);
869
+ const added = addElement(clone);
870
+ selectElement(added.id);
871
+ }
872
+ }
873
+ ev.stopPropagation();
874
+ });
875
+
876
+ // Floating rotation handle (works for all element types including arrows/pen)
877
+ _rotateEl = document.createElement('div');
878
+ _rotateEl.className = 'wb-rotate-float';
879
+ _rotateEl.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M1 4v6h6" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
880
+
881
+ _rotateEl.addEventListener('mousedown', (ev) => {
882
+ ev.stopPropagation();
883
+ ev.preventDefault();
884
+ const el2 = _elements.find(e2 => e2.id === id);
885
+ if (!el2) return;
886
+ const center = getElementCenter(el2);
887
+ const pt = clientToCanvas(ev.clientX, ev.clientY);
888
+ const dragData = {
889
+ type: 'rotate', id,
890
+ centerX: center.x, centerY: center.y,
891
+ origRotation: el2.rotation || 0,
892
+ startAngle: Math.atan2(pt.y - center.y, pt.x - center.x),
893
+ before: { ...el2, points: el2.points ? el2.points.map(p => [...p]) : undefined },
894
+ };
895
+ // Store original points for point-based rotation (arrows + pen)
896
+ if ((el2.type === 'arrow' || el2.type === 'pen') && el2.points) {
897
+ dragData.origPoints = el2.points.map(p => [...p]);
898
+ }
899
+ _drag = dragData;
900
+ });
901
+
902
+ // Floating move handle for text/list elements (lets you drag even in edit mode)
903
+ if (el.type === 'text' || el.type === 'list') {
904
+ _moveEl = document.createElement('div');
905
+ _moveEl.className = 'wb-move-float';
906
+ _moveEl.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
907
+
908
+ _moveEl.addEventListener('mousedown', (ev) => {
909
+ ev.stopPropagation();
910
+ ev.preventDefault();
911
+ exitEditMode();
912
+ exitListEditMode();
913
+ const el2 = _elements.find(e2 => e2.id === id);
914
+ if (!el2) return;
915
+ _drag = {
916
+ type: 'move', id,
917
+ startX: ev.clientX, startY: ev.clientY,
918
+ origX: el2.x, origY: el2.y,
919
+ before: { ...el2 },
920
+ };
921
+ });
922
+
923
+ _moveEl.style.zIndex = _nextZIndex + 100;
924
+ _root.appendChild(_moveEl);
925
+ }
926
+
927
+ _ctxMenu.style.zIndex = _nextZIndex + 100;
928
+ _rotateEl.style.zIndex = _nextZIndex + 100;
929
+ _root.appendChild(_ctxMenu);
930
+ _root.appendChild(_rotateEl);
931
+ positionContextMenu(el);
932
+ }
933
+
934
+ /** Get the visual center of any element type. */
935
+ function getElementCenter(el) {
936
+ if ((el.type === 'arrow' || el.type === 'pen') && el.points?.length) {
937
+ const cx = el.points.reduce((s, p) => s + p[0], 0) / el.points.length;
938
+ const cy = el.points.reduce((s, p) => s + p[1], 0) / el.points.length;
939
+ return { x: cx, y: cy };
940
+ }
941
+ return { x: el.x + (el.width || 0) / 2, y: el.y + (el.height || 0) / 2 };
942
+ }
943
+
944
+ function positionContextMenu(el) {
945
+ const rootRect = _root?.getBoundingClientRect();
946
+ if (!rootRect) return;
947
+
948
+ let cx, topY, rightX, bottomY, leftX;
949
+ if ((el.type === 'arrow' || el.type === 'pen') && el.points?.length) {
950
+ const xs = el.points.map(p => p[0]);
951
+ const ys = el.points.map(p => p[1]);
952
+ cx = xs.reduce((s, v) => s + v, 0) / xs.length;
953
+ topY = Math.min(...ys);
954
+ leftX = Math.min(...xs);
955
+ rightX = Math.max(...xs);
956
+ bottomY = Math.max(...ys);
957
+ } else {
958
+ let ew = el.width || 0, eh = el.height || 0;
959
+ // Text/list auto-size: read actual dimensions from DOM
960
+ if ((el.type === 'text' || el.type === 'list') && (!ew || !eh)) {
961
+ const dom = _elementsDiv?.querySelector(`[data-wb-id="${el.id}"]`);
962
+ if (dom) { ew = dom.offsetWidth; eh = dom.offsetHeight; }
963
+ }
964
+ cx = el.x + ew / 2;
965
+ topY = el.y;
966
+ leftX = el.x;
967
+ rightX = el.x + ew;
968
+ bottomY = el.y + eh;
969
+ }
970
+
971
+ // Context menu — centered above element
972
+ if (_ctxMenu) {
973
+ const menuW = 110;
974
+ let left = cx - menuW / 2;
975
+ let top = topY - 44;
976
+ left = Math.max(8, Math.min(rootRect.width - menuW - 8, left));
977
+ top = Math.max(8, top);
978
+ _ctxMenu.style.left = left + 'px';
979
+ _ctxMenu.style.top = top + 'px';
980
+ }
981
+
982
+ // Rotate handle — right side of element
983
+ if (_rotateEl) {
984
+ const elH = bottomY - topY;
985
+ let rLeft = rightX + 12;
986
+ let rTop = topY + (elH / 2) - 12;
987
+ rLeft = Math.min(rootRect.width - 32, rLeft);
988
+ rTop = Math.max(8, rTop);
989
+ _rotateEl.style.left = rLeft + 'px';
990
+ _rotateEl.style.top = rTop + 'px';
991
+ }
992
+
993
+ // Move handle — left side of element (text/list only)
994
+ if (_moveEl) {
995
+ const elH = bottomY - topY;
996
+ let mLeft = leftX - 44;
997
+ let mTop = topY + (elH / 2) - 12;
998
+ mLeft = Math.max(8, mLeft);
999
+ mTop = Math.max(8, mTop);
1000
+ _moveEl.style.left = mLeft + 'px';
1001
+ _moveEl.style.top = mTop + 'px';
1002
+ }
1003
+ }
1004
+
1005
+ /** Get the combined bounding box of all selected elements. */
1006
+ function getMultiSelectBounds() {
1007
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1008
+ for (const id of _selectedIds) {
1009
+ const el = _elements.find(e => e.id === id);
1010
+ if (!el) continue;
1011
+ if ((el.type === 'arrow' || el.type === 'pen') && el.points?.length) {
1012
+ for (const [px, py] of el.points) {
1013
+ if (px < minX) minX = px;
1014
+ if (py < minY) minY = py;
1015
+ if (px > maxX) maxX = px;
1016
+ if (py > maxY) maxY = py;
1017
+ }
1018
+ } else {
1019
+ const ex = el.x || 0, ey = el.y || 0;
1020
+ const ew = el.width || 0, eh = el.height || 0;
1021
+ if (ex < minX) minX = ex;
1022
+ if (ey < minY) minY = ey;
1023
+ if (ex + ew > maxX) maxX = ex + ew;
1024
+ if (ey + eh > maxY) maxY = ey + eh;
1025
+ }
1026
+ }
1027
+ return { minX, minY, maxX, maxY };
1028
+ }
1029
+
1030
+ function showMultiContextMenu() {
1031
+ hideContextMenu();
1032
+ if (_selectedIds.size < 2 || !_root) return;
1033
+
1034
+ _ctxMenu = document.createElement('div');
1035
+ _ctxMenu.className = 'wb-ctx-menu';
1036
+
1037
+ const count = _selectedIds.size;
1038
+ _ctxMenu.innerHTML = `
1039
+ <span class="wb-ctx-count">${count}</span>
1040
+ <button class="wb-ctx-btn" data-action="duplicate-multi" data-tooltip="Duplicate All"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
1041
+ <button class="wb-ctx-btn wb-ctx-danger" data-action="delete-multi" data-tooltip="Delete All"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
1042
+ `;
1043
+
1044
+ _ctxMenu.addEventListener('click', (ev) => {
1045
+ const btn = ev.target.closest('[data-action]');
1046
+ if (!btn) return;
1047
+ const action = btn.dataset.action;
1048
+ if (action === 'delete-multi') {
1049
+ deleteMultipleElements([..._selectedIds]);
1050
+ } else if (action === 'duplicate-multi') {
1051
+ duplicateMultipleElements([..._selectedIds]);
1052
+ }
1053
+ ev.stopPropagation();
1054
+ });
1055
+
1056
+ _ctxMenu.style.zIndex = _nextZIndex + 100;
1057
+ _root.appendChild(_ctxMenu);
1058
+ positionContextMenuMulti();
1059
+ }
1060
+
1061
+ function positionContextMenuMulti() {
1062
+ if (!_ctxMenu || _selectedIds.size < 2) return;
1063
+ const rootRect = _root?.getBoundingClientRect();
1064
+ if (!rootRect) return;
1065
+
1066
+ const bounds = getMultiSelectBounds();
1067
+ const cx = (bounds.minX + bounds.maxX) / 2;
1068
+ const menuW = 90;
1069
+ let left = cx - menuW / 2;
1070
+ let top = bounds.minY - 44;
1071
+ left = Math.max(8, Math.min(rootRect.width - menuW - 8, left));
1072
+ top = Math.max(8, top);
1073
+ _ctxMenu.style.left = left + 'px';
1074
+ _ctxMenu.style.top = top + 'px';
1075
+ }
1076
+
1077
+ function hideContextMenu() {
1078
+ if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
1079
+ if (_rotateEl) { _rotateEl.remove(); _rotateEl = null; }
1080
+ if (_moveEl) { _moveEl.remove(); _moveEl = null; }
1081
+ }
1082
+
1083
+
1084
+ // ═══════════════════════════════════════════
1085
+ // TOOL SWITCHING
1086
+ // ═══════════════════════════════════════════
1087
+
1088
+ function setTool(name) {
1089
+ _activeTool = name;
1090
+ _arrowCreating = null;
1091
+ _sectionCreating = null;
1092
+ clearArrowPreview();
1093
+ hideArrowHint();
1094
+
1095
+ if (_toolbar) {
1096
+ _toolbar.querySelectorAll('.wb-tool[data-tool]').forEach(btn => {
1097
+ btn.classList.toggle('active', btn.dataset.tool === name);
1098
+ });
1099
+ }
1100
+
1101
+ if (_root) {
1102
+ _root.classList.remove('tool-text', 'tool-list', 'tool-arrow', 'tool-shape', 'tool-pen', 'tool-section');
1103
+ if (name === 'text') _root.classList.add('tool-text');
1104
+ if (name === 'list') _root.classList.add('tool-list');
1105
+ if (name === 'arrow') _root.classList.add('tool-arrow');
1106
+ if (name === 'shape') _root.classList.add('tool-shape');
1107
+ if (name === 'pen') _root.classList.add('tool-pen');
1108
+ if (name === 'section') _root.classList.add('tool-section');
1109
+ }
1110
+
1111
+ // Show/hide shape picker
1112
+ const shapePicker = document.getElementById('wb-shape-picker');
1113
+ if (shapePicker) {
1114
+ if (name === 'shape') {
1115
+ shapePicker.style.display = 'flex';
1116
+ const btn = _toolbar?.querySelector('[data-tool="shape"]');
1117
+ if (btn && _toolbar) {
1118
+ const btnRect = btn.getBoundingClientRect();
1119
+ const toolbarRect = _toolbar.getBoundingClientRect();
1120
+ shapePicker.style.top = (btnRect.top - toolbarRect.top) + 'px';
1121
+ }
1122
+ } else {
1123
+ shapePicker.style.display = 'none';
1124
+ }
1125
+ }
1126
+
1127
+ // Show/hide section picker
1128
+ const sectionPicker = document.getElementById('wb-section-picker');
1129
+ if (sectionPicker) {
1130
+ if (name === 'section') {
1131
+ sectionPicker.style.display = 'grid';
1132
+ const btn = _toolbar?.querySelector('[data-tool="section"]');
1133
+ if (btn && _toolbar) {
1134
+ const btnRect = btn.getBoundingClientRect();
1135
+ const toolbarRect = _toolbar.getBoundingClientRect();
1136
+ sectionPicker.style.top = (btnRect.top - toolbarRect.top) + 'px';
1137
+ }
1138
+ } else {
1139
+ sectionPicker.style.display = 'none';
1140
+ }
1141
+ }
1142
+
1143
+ updateLockIndicator();
1144
+ }
1145
+
1146
+ /** Toggle multi-select marquee mode on/off. */
1147
+ function toggleMultiSelectMode(on) {
1148
+ _multiSelectMode = on;
1149
+ const btn = $('wb-multiselect');
1150
+ if (btn) btn.classList.toggle('active', on);
1151
+ if (_root) _root.classList.toggle('tool-multiselect', on);
1152
+ if (on) {
1153
+ setTool('select');
1154
+ }
1155
+ }
1156
+
1157
+ /** Auto-revert to select unless Ctrl is held (tool lock). */
1158
+ function autoRevert() {
1159
+ if (!_toolLocked) setTool('select');
1160
+ }
1161
+
1162
+ /** Update the lock badge on the active toolbar button. */
1163
+ function updateLockIndicator() {
1164
+ if (!_toolbar) return;
1165
+ _toolbar.querySelectorAll('.wb-tool[data-tool]').forEach(btn => {
1166
+ btn.classList.toggle('locked', _toolLocked && btn.dataset.tool === _activeTool && _activeTool !== 'select');
1167
+ });
1168
+ }
1169
+
1170
+ /** Track Ctrl key state for tool lock. */
1171
+ function onCtrlTrack(e) {
1172
+ const held = e.ctrlKey || e.metaKey;
1173
+ if (held !== _toolLocked) {
1174
+ _toolLocked = held;
1175
+ updateLockIndicator();
1176
+ }
1177
+ }
1178
+
1179
+
1180
+ // ═══════════════════════════════════════════
1181
+ // HAND-DRAWN SHAPE GENERATOR
1182
+ // ═══════════════════════════════════════════
1183
+
1184
+ /** Seeded random for consistent wobble per element ID. */
1185
+ function _seededRand(seed) {
1186
+ let h = 0;
1187
+ for (let i = 0; i < seed.length; i++) {
1188
+ h = ((h << 5) - h + seed.charCodeAt(i)) | 0;
1189
+ }
1190
+ return () => {
1191
+ h = (h * 16807 + 0) % 2147483647;
1192
+ return (h & 0x7fffffff) / 2147483647;
1193
+ };
1194
+ }
1195
+
1196
+ /** Generate a hand-drawn SVG path for a shape type. */
1197
+ function generateHandDrawnShape(shape, w, h, id) {
1198
+ const rand = _seededRand(id || 'default');
1199
+ const j = (amt = 4) => (rand() - 0.5) * amt;
1200
+ const m = 5; // margin from edges
1201
+
1202
+ if (shape === 'triangle') {
1203
+ // Wobbly triangle — 3 corners with midpoint wobble
1204
+ const pts = [
1205
+ [w / 2 + j(3), m + j(2)], // top center
1206
+ [w - m + j(3), h - m + j(2)], // bottom right
1207
+ [m + j(3), h - m + j(2)], // bottom left
1208
+ ];
1209
+ return `M ${pts[0][0]} ${pts[0][1]} L ${pts[1][0]} ${pts[1][1]} L ${pts[2][0]} ${pts[2][1]} Z`;
1210
+ }
1211
+
1212
+ if (shape === 'circle') {
1213
+ // Perfect ellipse using two SVG arcs
1214
+ const cx = w / 2, cy = h / 2;
1215
+ const rx = (w / 2) - m, ry = (h / 2) - m;
1216
+ return `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 1 ${cx + rx} ${cy} A ${rx} ${ry} 0 1 1 ${cx - rx} ${cy} Z`;
1217
+ }
1218
+
1219
+ if (shape === 'pill') {
1220
+ // Pill/capsule — rounded caps with Catmull-Rom (no sharp corners)
1221
+ const r = Math.min(w, h) / 2 - m;
1222
+ const pts = [];
1223
+ // Top edge (left to right)
1224
+ pts.push([m + r + j(), m + j(2)]);
1225
+ pts.push([w / 2 + j(), m + j(2)]);
1226
+ pts.push([w - m - r + j(), m + j(2)]);
1227
+ // Right cap (6 points for smooth semicircle)
1228
+ for (let i = 0; i <= 5; i++) {
1229
+ const a = -Math.PI / 2 + (i / 5) * Math.PI;
1230
+ pts.push([w - m - r + Math.cos(a) * (r + j(2)), h / 2 + Math.sin(a) * (r + j(2))]);
1231
+ }
1232
+ // Bottom edge (right to left)
1233
+ pts.push([w - m - r + j(), h - m + j(2)]);
1234
+ pts.push([w / 2 + j(), h - m + j(2)]);
1235
+ pts.push([m + r + j(), h - m + j(2)]);
1236
+ // Left cap (6 points)
1237
+ for (let i = 0; i <= 5; i++) {
1238
+ const a = Math.PI / 2 + (i / 5) * Math.PI;
1239
+ pts.push([m + r + Math.cos(a) * (r + j(2)), h / 2 + Math.sin(a) * (r + j(2))]);
1240
+ }
1241
+ return closedSmoothPath(pts);
1242
+ }
1243
+
1244
+ // Default: rect — rounded corners
1245
+ const r = Math.min(12, Math.min(w, h) / 4); // corner radius, max 12px
1246
+ const x1 = m, y1 = m, x2 = w - m, y2 = h - m;
1247
+
1248
+ return `M ${x1 + r} ${y1}`
1249
+ + ` L ${x2 - r} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y1 + r}`
1250
+ + ` L ${x2} ${y2 - r} A ${r} ${r} 0 0 1 ${x2 - r} ${y2}`
1251
+ + ` L ${x1 + r} ${y2} A ${r} ${r} 0 0 1 ${x1} ${y2 - r}`
1252
+ + ` L ${x1} ${y1 + r} A ${r} ${r} 0 0 1 ${x1 + r} ${y1}`
1253
+ + ` Z`;
1254
+ }
1255
+
1256
+
1257
+ // ═══════════════════════════════════════════
1258
+ // PEN SMOOTHING ENGINE
1259
+ // ═══════════════════════════════════════════
1260
+
1261
+ /** Simplify points using Ramer-Douglas-Peucker algorithm. */
1262
+ /** Smooth points using Laplacian (moving average) — preserves stroke shape, removes jitter. */
1263
+ function smoothPoints(pts, iterations = 3) {
1264
+ if (pts.length <= 2) return pts;
1265
+ let result = pts;
1266
+ // Chaikin corner-cutting: subdivides AND smooths each pass
1267
+ for (let p = 0; p < iterations; p++) {
1268
+ const next = [result[0]]; // anchor first point
1269
+ for (let i = 0; i < result.length - 1; i++) {
1270
+ const a = result[i], b = result[i + 1];
1271
+ next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
1272
+ next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
1273
+ }
1274
+ next.push(result[result.length - 1]); // anchor last point
1275
+ result = next;
1276
+ }
1277
+ // Cap point count to keep SVG path data reasonable
1278
+ if (result.length > 500) {
1279
+ const step = Math.ceil(result.length / 500);
1280
+ const thinned = [result[0]];
1281
+ for (let i = step; i < result.length - 1; i += step) thinned.push(result[i]);
1282
+ thinned.push(result[result.length - 1]);
1283
+ return thinned;
1284
+ }
1285
+ return result;
1286
+ }
1287
+
1288
+ /** Convert points to a smooth SVG path using Catmull-Rom → cubic bezier (open path). */
1289
+ function pointsToSmoothPath(pts) {
1290
+ if (pts.length === 0) return '';
1291
+ if (pts.length === 1) return `M ${pts[0][0]} ${pts[0][1]}`;
1292
+ if (pts.length === 2) return `M ${pts[0][0]} ${pts[0][1]} L ${pts[1][0]} ${pts[1][1]}`;
1293
+
1294
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
1295
+
1296
+ for (let i = 0; i < pts.length - 1; i++) {
1297
+ const p0 = pts[Math.max(0, i - 1)];
1298
+ const p1 = pts[i];
1299
+ const p2 = pts[Math.min(pts.length - 1, i + 1)];
1300
+ const p3 = pts[Math.min(pts.length - 1, i + 2)];
1301
+
1302
+ const t = 1 / 6;
1303
+ const cp1x = p1[0] + (p2[0] - p0[0]) * t;
1304
+ const cp1y = p1[1] + (p2[1] - p0[1]) * t;
1305
+ const cp2x = p2[0] - (p3[0] - p1[0]) * t;
1306
+ const cp2y = p2[1] - (p3[1] - p1[1]) * t;
1307
+
1308
+ d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2[0]} ${p2[1]}`;
1309
+ }
1310
+ return d;
1311
+ }
1312
+
1313
+ /** Convert points to a smooth CLOSED SVG path — wraps neighbors around for proper closure. */
1314
+ function closedSmoothPath(pts) {
1315
+ if (pts.length < 3) return pointsToSmoothPath(pts) + ' Z';
1316
+ const n = pts.length;
1317
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
1318
+
1319
+ for (let i = 0; i < n; i++) {
1320
+ const p0 = pts[(i - 1 + n) % n];
1321
+ const p1 = pts[i];
1322
+ const p2 = pts[(i + 1) % n];
1323
+ const p3 = pts[(i + 2) % n];
1324
+
1325
+ const t = 1 / 6;
1326
+ const cp1x = p1[0] + (p2[0] - p0[0]) * t;
1327
+ const cp1y = p1[1] + (p2[1] - p0[1]) * t;
1328
+ const cp2x = p2[0] - (p3[0] - p1[0]) * t;
1329
+ const cp2y = p2[1] - (p3[1] - p1[1]) * t;
1330
+
1331
+ d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2[0]} ${p2[1]}`;
1332
+ }
1333
+ return d + ' Z';
1334
+ }
1335
+
1336
+ // ═══════════════════════════════════════════
1337
+ // SHAPE DETECTION
1338
+ // ═══════════════════════════════════════════
1339
+
1340
+ /** Generate a drawn-circle path with overshoot tail (reference: hand-drawn circle aesthetic). */
1341
+ function generateDrawnCirclePath(w, h, id) {
1342
+ const rand = _seededRand(id || 'drawn');
1343
+ const j = (amt = 3) => (rand() - 0.5) * amt;
1344
+ const cx = w / 2, cy = h / 2;
1345
+ const rx = (w / 2) - 6, ry = (h / 2) - 6;
1346
+ const m = 6; // padding
1347
+
1348
+ // Generate points around more than 360° for overshoot
1349
+ const pts = [];
1350
+ const n = 16;
1351
+ const overshoot = 0.6; // radians past full circle (~35°)
1352
+ const totalAngle = Math.PI * 2 + overshoot;
1353
+
1354
+ for (let i = 0; i <= n; i++) {
1355
+ const t = i / n;
1356
+ const a = -0.2 + t * totalAngle; // start slightly before 0
1357
+ // Wobble decreases near the tail (end) for smooth taper
1358
+ const wobble = i < n ? j(4) : j(1);
1359
+ pts.push([
1360
+ cx + Math.cos(a) * (rx + wobble),
1361
+ cy + Math.sin(a) * (ry + wobble),
1362
+ ]);
1363
+ }
1364
+ return pointsToSmoothPath(pts); // open path — no Z
1365
+ }
1366
+
1367
+
1368
+ /** Start drawing a pen stroke — creates a live preview path. */
1369
+ function penStart(x, y) {
1370
+ _penPoints = [[x, y]];
1371
+ _penLiveEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1372
+ _penLiveEl.setAttribute('fill', 'none');
1373
+ _penLiveEl.setAttribute('stroke', _activeColor);
1374
+ _penLiveEl.setAttribute('stroke-width', '3');
1375
+ _penLiveEl.setAttribute('stroke-linecap', 'round');
1376
+ _penLiveEl.setAttribute('stroke-linejoin', 'round');
1377
+ _penLiveEl.setAttribute('d', `M ${x} ${y}`);
1378
+ _arrowsSvg?.appendChild(_penLiveEl);
1379
+ }
1380
+
1381
+ /** Add a point during drawing — smooth preview in real-time. */
1382
+ function penMove(x, y) {
1383
+ if (!_penPoints) return;
1384
+ const last = _penPoints[_penPoints.length - 1];
1385
+ const dist = Math.hypot(x - last[0], y - last[1]);
1386
+ if (dist < 2) return; // skip jitter
1387
+ _penPoints.push([x, y]);
1388
+
1389
+ // Real-time smooth preview
1390
+ if (_penLiveEl) {
1391
+ _penLiveEl.setAttribute('d', pointsToSmoothPath(_penPoints));
1392
+ }
1393
+ }
1394
+
1395
+ /** Finish the pen stroke — simplify, smooth, and create a permanent element. */
1396
+ function penEnd() {
1397
+ if (!_penPoints || _penPoints.length < 2) {
1398
+ // Too short — discard
1399
+ if (_penLiveEl) _penLiveEl.remove();
1400
+ _penLiveEl = null;
1401
+ _penPoints = null;
1402
+ return;
1403
+ }
1404
+
1405
+ // Remove live preview
1406
+ if (_penLiveEl) _penLiveEl.remove();
1407
+ _penLiveEl = null;
1408
+
1409
+ // Smooth jitter while preserving stroke shape
1410
+ const smoothed = smoothPoints(_penPoints, 3);
1411
+ _penPoints = null;
1412
+
1413
+ // Pen stroke — always kept as freehand (no auto-shape correction)
1414
+ const pathD = pointsToSmoothPath(smoothed);
1415
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1416
+ for (const [px, py] of smoothed) {
1417
+ if (px < minX) minX = px;
1418
+ if (py < minY) minY = py;
1419
+ if (px > maxX) maxX = px;
1420
+ if (py > maxY) maxY = py;
1421
+ }
1422
+
1423
+ const penEl = addElement({
1424
+ id: _genId(),
1425
+ type: 'pen',
1426
+ points: smoothed,
1427
+ pathD,
1428
+ x: minX, y: minY,
1429
+ width: maxX - minX, height: maxY - minY,
1430
+ color: _activeColor,
1431
+ strokeWidth: 3,
1432
+ });
1433
+ selectElement(penEl.id);
1434
+ autoRevert();
1435
+ }
1436
+
1437
+
1438
+ // ═══════════════════════════════════════════
1439
+ // ARROW PREVIEW (while placing)
1440
+ // ═══════════════════════════════════════════
1441
+
1442
+ function showArrowPreview(placedPts, cursorX, cursorY) {
1443
+ if (!_arrowPreview) return;
1444
+ // Clear previous preview paths
1445
+ while (_arrowPreview.querySelector('path')) _arrowPreview.querySelector('path').remove();
1446
+
1447
+ // Solid path for already-placed segments (2+ placed points)
1448
+ if (placedPts.length >= 2) {
1449
+ const solid = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1450
+ solid.setAttribute('d', computeArrowPath(placedPts));
1451
+ solid.setAttribute('fill', 'none');
1452
+ solid.setAttribute('stroke', 'rgba(255,255,255,0.5)');
1453
+ solid.setAttribute('stroke-width', '5');
1454
+ solid.setAttribute('stroke-linecap', 'round');
1455
+ solid.setAttribute('stroke-linejoin', 'round');
1456
+ _arrowPreview.appendChild(solid);
1457
+ }
1458
+
1459
+ // Dashed preview — full path including cursor position
1460
+ const previewPts = [...placedPts, [cursorX, cursorY]];
1461
+ const dash = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1462
+ dash.setAttribute('d', computeArrowPath(previewPts));
1463
+ dash.setAttribute('fill', 'none');
1464
+ dash.setAttribute('stroke', 'rgba(255,255,255,0.35)');
1465
+ dash.setAttribute('stroke-width', '5');
1466
+ dash.setAttribute('stroke-dasharray', '10 6');
1467
+ dash.setAttribute('stroke-linecap', 'round');
1468
+ dash.setAttribute('marker-end', 'url(#wb-arrowhead-preview)');
1469
+ _arrowPreview.appendChild(dash);
1470
+ }
1471
+
1472
+ function clearArrowPreview() {
1473
+ if (!_arrowPreview) return;
1474
+ const path = _arrowPreview.querySelector('path');
1475
+ if (path) path.remove();
1476
+ }
1477
+
1478
+
1479
+ function showArrowHint(x, y) {
1480
+ if (!_arrowHint) return;
1481
+ _arrowHint.style.left = (x + 16) + 'px';
1482
+ _arrowHint.style.top = (y - 32) + 'px';
1483
+ _arrowHint.classList.add('visible');
1484
+ }
1485
+
1486
+ function hideArrowHint() {
1487
+ if (!_arrowHint) return;
1488
+ _arrowHint.classList.remove('visible');
1489
+ }
1490
+
1491
+ function finalizeArrow() {
1492
+ hideArrowHint();
1493
+ if (!_arrowCreating || _arrowCreating.points.length < 2) {
1494
+ _arrowCreating = null;
1495
+ clearArrowPreview();
1496
+ return;
1497
+ }
1498
+ const pts = _arrowCreating.points;
1499
+ const lastPt = pts[pts.length - 1];
1500
+ const endAnchor = findNearestAnchor(lastPt[0], lastPt[1], null);
1501
+ const arr = addElement({
1502
+ id: _genId(),
1503
+ type: 'arrow',
1504
+ points: pts.map(p => [...p]),
1505
+ startAnchor: _arrowCreating.startAnchor,
1506
+ endAnchor: endAnchor || null,
1507
+ color: _activeColor,
1508
+ });
1509
+ _arrowCreating = null;
1510
+ clearArrowPreview();
1511
+ selectElement(arr.id);
1512
+ autoRevert();
1513
+ }
1514
+
1515
+
1516
+ // ═══════════════════════════════════════════
1517
+ // TEXT / FORM EDITING
1518
+ // ═══════════════════════════════════════════
1519
+
1520
+ function enterEditMode(dom, elId) {
1521
+ const content = dom.querySelector('.wb-text-content');
1522
+ if (!content) return;
1523
+ content.setAttribute('contenteditable', 'true');
1524
+ dom.classList.add('editing');
1525
+ content.focus();
1526
+
1527
+ const range = document.createRange();
1528
+ range.selectNodeContents(content);
1529
+ const sel = window.getSelection();
1530
+ sel.removeAllRanges();
1531
+ sel.addRange(range);
1532
+
1533
+ const onBlur = () => {
1534
+ content.setAttribute('contenteditable', 'false');
1535
+ dom.classList.remove('editing');
1536
+ content.removeEventListener('blur', onBlur);
1537
+
1538
+ const el = _elements.find(e => e.id === elId);
1539
+ if (el) {
1540
+ const before = { ...el };
1541
+ el.content = content.innerText.trimEnd() || '';
1542
+ pushUndo('edit', before, { ...el });
1543
+ persistDebounced();
1544
+ }
1545
+ };
1546
+ content.addEventListener('blur', onBlur);
1547
+ }
1548
+
1549
+ function exitEditMode() {
1550
+ const editing = _root?.querySelector('.wb-text.editing .wb-text-content');
1551
+ if (editing) editing.blur();
1552
+ }
1553
+
1554
+ function enterListEditMode(dom, elId) {
1555
+ const ul = dom.querySelector('ul');
1556
+ if (!ul) return;
1557
+ dom.classList.add('editing');
1558
+
1559
+ // Make all existing items editable
1560
+ [...ul.querySelectorAll('li')].forEach(li => li.setAttribute('contenteditable', 'true'));
1561
+
1562
+ // Focus last item, cursor at end
1563
+ const lis = [...ul.querySelectorAll('li')];
1564
+ const target = lis[lis.length - 1] || null;
1565
+ if (target) {
1566
+ target.focus();
1567
+ const range = document.createRange();
1568
+ range.selectNodeContents(target);
1569
+ range.collapse(false);
1570
+ window.getSelection().removeAllRanges();
1571
+ window.getSelection().addRange(range);
1572
+ }
1573
+ }
1574
+
1575
+ function exitListEditMode() {
1576
+ const dom = _root?.querySelector('.wb-list.editing');
1577
+ if (!dom) return;
1578
+ const ul = dom.querySelector('ul');
1579
+ const elId = dom.dataset.wbId;
1580
+
1581
+ dom.classList.remove('editing');
1582
+ [...ul.querySelectorAll('li')].forEach(li => li.setAttribute('contenteditable', 'false'));
1583
+ // Re-blur in case still focused
1584
+ if (document.activeElement?.closest('.wb-list')) document.activeElement.blur();
1585
+
1586
+ const el = _elements.find(e => e.id === elId);
1587
+ if (el) {
1588
+ const before = { ...el, items: [...(el.items || [])] };
1589
+ const rawItems = [...ul.querySelectorAll('li')].map(li => li.innerText.trimEnd());
1590
+ el.items = rawItems.filter((t, i, arr) => t !== '' || arr.length === 1);
1591
+ if (!el.items.length) el.items = [''];
1592
+ pushUndo('edit', before, { ...el, items: [...el.items] });
1593
+ renderElements();
1594
+ persistDebounced();
1595
+ }
1596
+ }
1597
+
1598
+
1599
+ // ═══════════════════════════════════════════
1600
+ // MULTI-SELECT HELPERS
1601
+ // ═══════════════════════════════════════════
1602
+
1603
+ /** Start dragging all selected elements together. */
1604
+ function _startMultiDrag(e) {
1605
+ const snapshots = [];
1606
+ for (const id of _selectedIds) {
1607
+ const el = _elements.find(el2 => el2.id === id);
1608
+ if (!el) continue;
1609
+ snapshots.push({
1610
+ id,
1611
+ origX: el.x, origY: el.y,
1612
+ origPoints: el.points ? el.points.map(p => [...p]) : null,
1613
+ before: { ...el, points: el.points ? el.points.map(p => [...p]) : undefined },
1614
+ });
1615
+ }
1616
+ _drag = {
1617
+ type: 'move-multi',
1618
+ startX: e.clientX, startY: e.clientY,
1619
+ snapshots,
1620
+ };
1621
+ }
1622
+
1623
+ /** Check if an element's bounding box intersects a rectangle. */
1624
+ function _elementIntersectsRect(el, rx, ry, rw, rh) {
1625
+ let ex, ey, ew, eh;
1626
+ if ((el.type === 'arrow' || el.type === 'pen') && el.points?.length) {
1627
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1628
+ for (const [px, py] of el.points) {
1629
+ if (px < minX) minX = px;
1630
+ if (py < minY) minY = py;
1631
+ if (px > maxX) maxX = px;
1632
+ if (py > maxY) maxY = py;
1633
+ }
1634
+ ex = minX; ey = minY; ew = maxX - minX; eh = maxY - minY;
1635
+ } else {
1636
+ ex = el.x || 0; ey = el.y || 0;
1637
+ ew = el.width || 0; eh = el.height || 0;
1638
+ // For text elements with zero width, estimate width from content
1639
+ if ((el.type === 'text' || el.type === 'list') && ew === 0) {
1640
+ const dom = _elementsDiv?.querySelector(`[data-wb-id="${el.id}"]`);
1641
+ if (dom) { ew = dom.offsetWidth; eh = dom.offsetHeight; }
1642
+ }
1643
+ }
1644
+ // AABB intersection test
1645
+ return !(ex + ew < rx || ex > rx + rw || ey + eh < ry || ey > ry + rh);
1646
+ }
1647
+
1648
+
1649
+ // ═══════════════════════════════════════════
1650
+ // EVENT HANDLERS — MOUSE
1651
+ // ═══════════════════════════════════════════
1652
+
1653
+ function onMouseDown(e) {
1654
+ if (!_root || !_root.closest('#static-bg.visible')) return;
1655
+ if (e.target.closest('#wb-toolbar')) return;
1656
+ if (e.target.closest('.wb-ctx-menu')) return;
1657
+ if (e.target.closest('.wb-rotate-float')) return;
1658
+ if (e.target.closest('.wb-move-float')) return;
1659
+ if (e.target.closest('.wb-drag-to-term')) return;
1660
+ if (e.button !== 0) return;
1661
+ // Block drawing for guests without whiteboard permission (select/view still allowed)
1662
+ if (isGuest() && !hasPermission('whiteboard') && _activeTool !== 'select') return;
1663
+
1664
+ // Steal focus from terminal/other elements so Ctrl+V paste works on whiteboard
1665
+ _root.focus({ preventScroll: true });
1666
+
1667
+ const pt = clientToCanvas(e.clientX, e.clientY);
1668
+
1669
+ // Check if clicking on an element
1670
+ const targetEl = e.target.closest('[data-wb-id]');
1671
+ const targetId = targetEl?.dataset.wbId;
1672
+
1673
+ // Check if clicking on an SVG path (arrow or pen stroke)
1674
+ const arrowPath = e.target.closest('path[data-wb-id]');
1675
+ const arrowId = arrowPath?.dataset.wbId;
1676
+
1677
+ if (_activeTool === 'select') {
1678
+ const additive = e.shiftKey || _multiSelectMode;
1679
+
1680
+ // Resize handle? (only when single-selected)
1681
+ if (e.target.closest('.wb-resize-handle') && targetId) {
1682
+ const el = _elements.find(e2 => e2.id === targetId);
1683
+ if (el) {
1684
+ selectElement(targetId);
1685
+ _drag = {
1686
+ type: 'resize', id: targetId,
1687
+ startX: e.clientX, startY: e.clientY,
1688
+ origW: el.width || 200, origH: el.height || 80,
1689
+ before: { ...el },
1690
+ };
1691
+ e.preventDefault();
1692
+ return;
1693
+ }
1694
+ }
1695
+
1696
+ // Click on element → select + start drag (or multi-drag)
1697
+ if (targetId) {
1698
+ exitEditMode();
1699
+ exitListEditMode();
1700
+ const el = _elements.find(e2 => e2.id === targetId);
1701
+ if (el && el.type !== 'arrow' && el.type !== 'pen') {
1702
+ if (additive) {
1703
+ selectElement(targetId, { additive: true });
1704
+ } else if (!_selectedIds.has(targetId)) {
1705
+ selectElement(targetId);
1706
+ }
1707
+ // Start multi-drag if multiple selected
1708
+ if (_selectedIds.size > 1) {
1709
+ _startMultiDrag(e);
1710
+ } else {
1711
+ _drag = {
1712
+ type: 'move', id: targetId,
1713
+ startX: e.clientX, startY: e.clientY,
1714
+ origX: el.x, origY: el.y,
1715
+ before: { ...el },
1716
+ };
1717
+ }
1718
+ e.preventDefault();
1719
+ return;
1720
+ }
1721
+ }
1722
+
1723
+ // Click on arrow/pen stroke → select + start drag
1724
+ if (arrowId) {
1725
+ exitEditMode();
1726
+ exitListEditMode();
1727
+ const el = _elements.find(e2 => e2.id === arrowId);
1728
+ if (el) {
1729
+ if (additive) {
1730
+ selectElement(arrowId, { additive: true });
1731
+ } else if (!_selectedIds.has(arrowId)) {
1732
+ selectElement(arrowId);
1733
+ }
1734
+ // Start multi-drag if multiple selected
1735
+ if (_selectedIds.size > 1) {
1736
+ _startMultiDrag(e);
1737
+ } else if (el.type === 'arrow') {
1738
+ _drag = {
1739
+ type: 'move-arrow', id: arrowId,
1740
+ startX: e.clientX, startY: e.clientY,
1741
+ origPoints: el.points.map(p => [...p]),
1742
+ before: { ...el, points: el.points.map(p => [...p]) },
1743
+ };
1744
+ } else if (el.type === 'pen') {
1745
+ _drag = {
1746
+ type: 'move-pen', id: arrowId,
1747
+ startX: e.clientX, startY: e.clientY,
1748
+ origX: el.x, origY: el.y,
1749
+ origPoints: el.points.map(p => [...p]),
1750
+ before: { ...el, points: el.points.map(p => [...p]) },
1751
+ };
1752
+ }
1753
+ }
1754
+ e.preventDefault();
1755
+ return;
1756
+ }
1757
+
1758
+ // Click on empty canvas
1759
+ exitEditMode();
1760
+ exitListEditMode();
1761
+ if (_multiSelectMode || additive) {
1762
+ // Multi-select mode or Shift held → start marquee drag
1763
+ if (!additive) deselectAll();
1764
+ _marquee = { startX: pt.x, startY: pt.y, rect: null };
1765
+ } else {
1766
+ // Normal mode → just deselect
1767
+ deselectAll();
1768
+ }
1769
+ e.preventDefault();
1770
+ return;
1771
+ }
1772
+
1773
+ if (_activeTool === 'text') {
1774
+ exitEditMode();
1775
+ exitListEditMode();
1776
+ const el = addElement({
1777
+ id: _genId(),
1778
+ type: 'text',
1779
+ x: _snap(pt.x),
1780
+ y: _snap(pt.y - 10),
1781
+ width: 0,
1782
+ height: 0, // auto-size
1783
+ content: '',
1784
+ fontSize: 22,
1785
+ color: _activeColor,
1786
+ });
1787
+ selectElement(el.id);
1788
+ requestAnimationFrame(() => {
1789
+ const dom = _elementsDiv?.querySelector(`[data-wb-id="${el.id}"]`);
1790
+ if (dom) enterEditMode(dom, el.id);
1791
+ });
1792
+ autoRevert();
1793
+ e.preventDefault();
1794
+ return;
1795
+ }
1796
+
1797
+ if (_activeTool === 'list') {
1798
+ exitEditMode();
1799
+ exitListEditMode();
1800
+ const el = addElement({
1801
+ id: _genId(),
1802
+ type: 'list',
1803
+ x: _snap(pt.x),
1804
+ y: _snap(pt.y - 10),
1805
+ width: 0,
1806
+ height: 0,
1807
+ items: [''],
1808
+ ordered: false,
1809
+ fontSize: 18,
1810
+ color: _activeColor,
1811
+ });
1812
+ selectElement(el.id);
1813
+ requestAnimationFrame(() => {
1814
+ const dom = _elementsDiv?.querySelector(`[data-wb-id="${el.id}"]`);
1815
+ if (dom) enterListEditMode(dom, el.id);
1816
+ });
1817
+ autoRevert();
1818
+ e.preventDefault();
1819
+ return;
1820
+ }
1821
+
1822
+ if (_activeTool === 'shape') {
1823
+ exitEditMode();
1824
+ exitListEditMode();
1825
+ deselectAll();
1826
+ const id = _genId();
1827
+ const el = {
1828
+ id,
1829
+ type: 'shape',
1830
+ shape: _activeShapeType,
1831
+ x: _snap(pt.x),
1832
+ y: _snap(pt.y),
1833
+ width: 1,
1834
+ height: 1,
1835
+ color: _activeColor,
1836
+ zIndex: _nextZIndex++,
1837
+ };
1838
+ _elements.push(el);
1839
+ _shapeCreating = { id, originX: pt.x, originY: pt.y };
1840
+ renderAll();
1841
+ e.preventDefault();
1842
+ return;
1843
+ }
1844
+
1845
+ if (_activeTool === 'section') {
1846
+ exitEditMode();
1847
+ exitListEditMode();
1848
+ deselectAll();
1849
+ const id = _genId();
1850
+ const def = SECTION_TYPES[_activeSectionType] || SECTION_TYPES.content;
1851
+ const el = {
1852
+ id,
1853
+ type: 'section',
1854
+ sectionType: _activeSectionType,
1855
+ label: def.label,
1856
+ x: _snap(pt.x),
1857
+ y: _snap(pt.y),
1858
+ width: 1,
1859
+ height: 1,
1860
+ color: def.color,
1861
+ zIndex: _nextZIndex++,
1862
+ };
1863
+ _elements.push(el);
1864
+ _sectionCreating = { id, originX: pt.x, originY: pt.y, sectionType: _activeSectionType };
1865
+ const picker = document.getElementById('wb-section-picker');
1866
+ if (picker) picker.style.display = 'none';
1867
+ renderAll();
1868
+ e.preventDefault();
1869
+ return;
1870
+ }
1871
+
1872
+ if (_activeTool === 'pen') {
1873
+ exitEditMode();
1874
+ exitListEditMode();
1875
+ deselectAll();
1876
+ penStart(pt.x, pt.y);
1877
+ e.preventDefault();
1878
+ return;
1879
+ }
1880
+
1881
+ if (_activeTool === 'arrow') {
1882
+ exitEditMode();
1883
+ exitListEditMode();
1884
+ if (!_arrowCreating) {
1885
+ // First click → start multi-point arrow
1886
+ const anchorId = findNearestAnchor(pt.x, pt.y, null);
1887
+ _arrowCreating = { points: [[pt.x, pt.y]], startAnchor: anchorId || null };
1888
+ showArrowHint(pt.x, pt.y);
1889
+ } else {
1890
+ // Subsequent click — check for double-click (same spot → finalize)
1891
+ const pts = _arrowCreating.points;
1892
+ const last = pts[pts.length - 1];
1893
+ const dist = Math.sqrt((pt.x - last[0]) ** 2 + (pt.y - last[1]) ** 2);
1894
+ if (dist < 8 && pts.length >= 2) {
1895
+ // Double-click detected → finalize arrow
1896
+ finalizeArrow();
1897
+ e.preventDefault();
1898
+ return;
1899
+ }
1900
+ // Add waypoint
1901
+ pts.push([pt.x, pt.y]);
1902
+ }
1903
+ e.preventDefault();
1904
+ return;
1905
+ }
1906
+ }
1907
+
1908
+ function onMouseMove(e) {
1909
+ if (!_root) return;
1910
+
1911
+ // Marquee selection drag
1912
+ if (_marquee) {
1913
+ const pt = clientToCanvas(e.clientX, e.clientY);
1914
+ const x = Math.min(_marquee.startX, pt.x);
1915
+ const y = Math.min(_marquee.startY, pt.y);
1916
+ const w = Math.abs(pt.x - _marquee.startX);
1917
+ const h = Math.abs(pt.y - _marquee.startY);
1918
+
1919
+ if (!_marquee.rect && (w > 4 || h > 4)) {
1920
+ _marquee.rect = document.createElement('div');
1921
+ _marquee.rect.className = 'wb-marquee';
1922
+ _root.appendChild(_marquee.rect);
1923
+ }
1924
+ if (_marquee.rect) {
1925
+ _marquee.rect.style.left = x + 'px';
1926
+ _marquee.rect.style.top = y + 'px';
1927
+ _marquee.rect.style.width = w + 'px';
1928
+ _marquee.rect.style.height = h + 'px';
1929
+ }
1930
+ return;
1931
+ }
1932
+
1933
+ // Multi-drag move
1934
+ if (_drag?.type === 'move-multi') {
1935
+ const rawDx = e.clientX - _drag.startX;
1936
+ const rawDy = e.clientY - _drag.startY;
1937
+ const dx = state.gridSnap ? (_snap(_drag.snapshots[0]?.origX + rawDx) - _drag.snapshots[0]?.origX) : rawDx;
1938
+ const dy = state.gridSnap ? (_snap(_drag.snapshots[0]?.origY + rawDy) - _drag.snapshots[0]?.origY) : rawDy;
1939
+ for (const snap of _drag.snapshots) {
1940
+ const el = _elements.find(el2 => el2.id === snap.id);
1941
+ if (!el) continue;
1942
+ if (el.type === 'arrow') {
1943
+ el.points = snap.origPoints.map(([px, py]) => [px + dx, py + dy]);
1944
+ el.startAnchor = null;
1945
+ el.endAnchor = null;
1946
+ } else if (el.type === 'pen') {
1947
+ el.x = snap.origX + dx;
1948
+ el.y = snap.origY + dy;
1949
+ el.points = snap.origPoints.map(([px, py]) => [px + dx, py + dy]);
1950
+ el.pathD = pointsToSmoothPath(el.points);
1951
+ } else {
1952
+ el.x = snap.origX + dx;
1953
+ el.y = snap.origY + dy;
1954
+ }
1955
+ }
1956
+ renderAll();
1957
+ return;
1958
+ }
1959
+
1960
+ // Shape drag-to-create
1961
+ if (_shapeCreating) {
1962
+ const pt = clientToCanvas(e.clientX, e.clientY);
1963
+ const el = _elements.find(el2 => el2.id === _shapeCreating.id);
1964
+ if (el) {
1965
+ const sx = _snap(Math.min(_shapeCreating.originX, pt.x));
1966
+ const sy = _snap(Math.min(_shapeCreating.originY, pt.y));
1967
+ el.x = sx;
1968
+ el.y = sy;
1969
+ el.width = Math.max(_snap(Math.abs(pt.x - _shapeCreating.originX)), 2);
1970
+ el.height = Math.max(_snap(Math.abs(pt.y - _shapeCreating.originY)), 2);
1971
+ renderAll();
1972
+ }
1973
+ return;
1974
+ }
1975
+
1976
+ // Section drag-to-create
1977
+ if (_sectionCreating) {
1978
+ const pt = clientToCanvas(e.clientX, e.clientY);
1979
+ const el = _elements.find(el2 => el2.id === _sectionCreating.id);
1980
+ if (el) {
1981
+ const sx = _snap(Math.min(_sectionCreating.originX, pt.x));
1982
+ const sy = _snap(Math.min(_sectionCreating.originY, pt.y));
1983
+ el.x = sx;
1984
+ el.y = sy;
1985
+ el.width = Math.max(_snap(Math.abs(pt.x - _sectionCreating.originX)), 2);
1986
+ el.height = Math.max(_snap(Math.abs(pt.y - _sectionCreating.originY)), 2);
1987
+ renderAll();
1988
+ }
1989
+ return;
1990
+ }
1991
+
1992
+ // Pen drawing
1993
+ if (_penPoints) {
1994
+ const rect = _root.getBoundingClientRect();
1995
+ penMove(e.clientX - rect.left, e.clientY - rect.top);
1996
+ return;
1997
+ }
1998
+
1999
+ // Drag move
2000
+ if (_drag?.type === 'move') {
2001
+ const dx = e.clientX - _drag.startX;
2002
+ const dy = e.clientY - _drag.startY;
2003
+ const el = _elements.find(el2 => el2.id === _drag.id);
2004
+ if (el) {
2005
+ el.x = _snap(_drag.origX + dx);
2006
+ el.y = _snap(_drag.origY + dy);
2007
+ renderAll();
2008
+ }
2009
+ return;
2010
+ }
2011
+
2012
+ // Drag move arrow (shift all points)
2013
+ if (_drag?.type === 'move-arrow') {
2014
+ const dx = e.clientX - _drag.startX;
2015
+ const dy = e.clientY - _drag.startY;
2016
+ const el = _elements.find(el2 => el2.id === _drag.id);
2017
+ if (el && el.points) {
2018
+ el.points = _drag.origPoints.map(([px, py]) => [px + dx, py + dy]);
2019
+ el.startAnchor = null;
2020
+ el.endAnchor = null;
2021
+ renderAll();
2022
+ }
2023
+ return;
2024
+ }
2025
+
2026
+ // Drag move pen stroke
2027
+ if (_drag?.type === 'move-pen') {
2028
+ const dx = e.clientX - _drag.startX;
2029
+ const dy = e.clientY - _drag.startY;
2030
+ const el = _elements.find(el2 => el2.id === _drag.id);
2031
+ if (el) {
2032
+ el.x = _drag.origX + dx;
2033
+ el.y = _drag.origY + dy;
2034
+ el.points = _drag.origPoints.map(([px, py]) => [px + dx, py + dy]);
2035
+ el.pathD = pointsToSmoothPath(el.points);
2036
+ renderAll();
2037
+ }
2038
+ return;
2039
+ }
2040
+
2041
+ // Drag rotate
2042
+ if (_drag?.type === 'rotate') {
2043
+ const pt2 = clientToCanvas(e.clientX, e.clientY);
2044
+ const angle = Math.atan2(pt2.y - _drag.centerY, pt2.x - _drag.centerX);
2045
+ const deltaRad = angle - _drag.startAngle;
2046
+ const deltaDeg = deltaRad * (180 / Math.PI);
2047
+ const el = _elements.find(el2 => el2.id === _drag.id);
2048
+ if (el) {
2049
+ // For arrows: rotate all points around centroid
2050
+ if (el.type === 'arrow' && _drag.origPoints) {
2051
+ const cx = _drag.centerX, cy = _drag.centerY;
2052
+ const cos = Math.cos(deltaRad), sin = Math.sin(deltaRad);
2053
+ el.points = _drag.origPoints.map(([px, py]) => [
2054
+ cx + (px - cx) * cos - (py - cy) * sin,
2055
+ cy + (px - cx) * sin + (py - cy) * cos,
2056
+ ]);
2057
+ el.startAnchor = null;
2058
+ el.endAnchor = null;
2059
+ }
2060
+ // For pen strokes: rotate all points around centroid, recalc bounding box
2061
+ else if (el.type === 'pen') {
2062
+ const cx = _drag.centerX, cy = _drag.centerY;
2063
+ const cos = Math.cos(deltaRad), sin = Math.sin(deltaRad);
2064
+ el.points = _drag.origPoints.map(([px, py]) => {
2065
+ const rx = cx + (px - cx) * cos - (py - cy) * sin;
2066
+ const ry = cy + (px - cx) * sin + (py - cy) * cos;
2067
+ return [rx, ry];
2068
+ });
2069
+ el.pathD = pointsToSmoothPath(el.points);
2070
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2071
+ for (const [px, py] of el.points) {
2072
+ if (px < minX) minX = px;
2073
+ if (py < minY) minY = py;
2074
+ if (px > maxX) maxX = px;
2075
+ if (py > maxY) maxY = py;
2076
+ }
2077
+ el.x = minX; el.y = minY;
2078
+ el.width = maxX - minX; el.height = maxY - minY;
2079
+ }
2080
+ // For DOM elements (text, shape, image): CSS rotation
2081
+ else {
2082
+ let newRot = _drag.origRotation + deltaDeg;
2083
+ // Snap to 0/90/180/270 when within 5°
2084
+ const snap = [0, 90, 180, 270, -90, -180, -270, 360];
2085
+ for (const s of snap) {
2086
+ if (Math.abs(newRot - s) < 5) { newRot = s; break; }
2087
+ }
2088
+ el.rotation = Math.round(newRot * 10) / 10;
2089
+ }
2090
+ renderAll();
2091
+ }
2092
+ return;
2093
+ }
2094
+
2095
+ // Drag resize
2096
+ if (_drag?.type === 'resize') {
2097
+ const dx = e.clientX - _drag.startX;
2098
+ const dy = e.clientY - _drag.startY;
2099
+ const el = _elements.find(el2 => el2.id === _drag.id);
2100
+ if (el) {
2101
+ el.width = _snap(Math.max(60, _drag.origW + dx));
2102
+ el.height = _snap(Math.max(30, _drag.origH + dy));
2103
+ renderAll();
2104
+ }
2105
+ return;
2106
+ }
2107
+
2108
+ // Arrow preview (multi-point)
2109
+ if (_activeTool === 'arrow' && _arrowCreating) {
2110
+ const pt = clientToCanvas(e.clientX, e.clientY);
2111
+ showArrowPreview(_arrowCreating.points, pt.x, pt.y);
2112
+ showArrowHint(pt.x, pt.y);
2113
+ }
2114
+ }
2115
+
2116
+ function onMouseUp(e) {
2117
+ // Finalize marquee selection
2118
+ if (_marquee) {
2119
+ if (_marquee.rect) {
2120
+ const pt = clientToCanvas(e.clientX, e.clientY);
2121
+ const rx = Math.min(_marquee.startX, pt.x);
2122
+ const ry = Math.min(_marquee.startY, pt.y);
2123
+ const rw = Math.abs(pt.x - _marquee.startX);
2124
+ const rh = Math.abs(pt.y - _marquee.startY);
2125
+
2126
+ const hitIds = [];
2127
+ for (const el of _elements) {
2128
+ if (_elementIntersectsRect(el, rx, ry, rw, rh)) {
2129
+ hitIds.push(el.id);
2130
+ }
2131
+ }
2132
+
2133
+ _marquee.rect.remove();
2134
+ if (hitIds.length) {
2135
+ if (e.shiftKey) {
2136
+ // Additive: merge with existing selection
2137
+ for (const id of hitIds) _selectedIds.add(id);
2138
+ _selectedId = hitIds[0];
2139
+ } else {
2140
+ selectMultiple(hitIds);
2141
+ }
2142
+ if (_selectedIds.size === 1) {
2143
+ showContextMenu([..._selectedIds][0]);
2144
+ } else if (_selectedIds.size > 1) {
2145
+ showMultiContextMenu();
2146
+ }
2147
+ renderAll();
2148
+ }
2149
+ }
2150
+ _marquee = null;
2151
+ return;
2152
+ }
2153
+
2154
+ // Finalize multi-drag
2155
+ if (_drag?.type === 'move-multi') {
2156
+ const dx = e.clientX - _drag.startX;
2157
+ const dy = e.clientY - _drag.startY;
2158
+ if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
2159
+ const befores = _drag.snapshots.map(s => ({ ...s.before }));
2160
+ const afters = _drag.snapshots.map(s => {
2161
+ const el = _elements.find(el2 => el2.id === s.id);
2162
+ return el ? { ...el, points: el.points ? el.points.map(p => [...p]) : undefined } : null;
2163
+ }).filter(Boolean);
2164
+ pushUndo('move-multi', befores, afters);
2165
+ }
2166
+ _drag = null;
2167
+ persistDebounced();
2168
+ return;
2169
+ }
2170
+
2171
+ // Finalize shape drag-to-create
2172
+ if (_shapeCreating) {
2173
+ const el = _elements.find(el2 => el2.id === _shapeCreating.id);
2174
+ if (el) {
2175
+ // Click without drag → fall back to default size
2176
+ if (el.width < 10 && el.height < 10) {
2177
+ el.x = _snap(_shapeCreating.originX - 80);
2178
+ el.y = _snap(_shapeCreating.originY - 50);
2179
+ el.width = 160;
2180
+ el.height = 100;
2181
+ }
2182
+ el.x = _snap(el.x);
2183
+ el.y = _snap(el.y);
2184
+ el.width = Math.max(el.width, 30);
2185
+ el.height = Math.max(el.height, 20);
2186
+ pushUndo('add', null, { ...el });
2187
+ selectElement(el.id);
2188
+ persistDebounced();
2189
+ autoRevert();
2190
+ }
2191
+ _shapeCreating = null;
2192
+ return;
2193
+ }
2194
+
2195
+ // Finalize section drag-to-create
2196
+ if (_sectionCreating) {
2197
+ const el = _elements.find(el2 => el2.id === _sectionCreating.id);
2198
+ if (el) {
2199
+ if (el.width < 10 && el.height < 10) {
2200
+ const def = SECTION_TYPES[el.sectionType] || SECTION_TYPES.content;
2201
+ el.x = _snap(_sectionCreating.originX - def.w / 2);
2202
+ el.y = _snap(_sectionCreating.originY - def.h / 2);
2203
+ el.width = def.w;
2204
+ el.height = def.h;
2205
+ }
2206
+ el.x = _snap(el.x);
2207
+ el.y = _snap(el.y);
2208
+ el.width = Math.max(el.width, 40);
2209
+ el.height = Math.max(el.height, 30);
2210
+ pushUndo('add', null, { ...el });
2211
+ selectElement(el.id);
2212
+ persistDebounced();
2213
+ autoRevert();
2214
+ }
2215
+ _sectionCreating = null;
2216
+ return;
2217
+ }
2218
+
2219
+ // Finalize pen stroke
2220
+ if (_penPoints) {
2221
+ penEnd();
2222
+ return;
2223
+ }
2224
+
2225
+ if (_drag) {
2226
+ const el = _elements.find(el2 => el2.id === _drag.id);
2227
+ if (el && _drag.before) {
2228
+ let hasMoved = false;
2229
+ if (_drag.type === 'move-arrow') {
2230
+ hasMoved = el.points?.[0]?.[0] !== _drag.before.points?.[0]?.[0] || el.points?.[0]?.[1] !== _drag.before.points?.[0]?.[1];
2231
+ } else if (_drag.type === 'move-pen') {
2232
+ hasMoved = el.x !== _drag.before.x || el.y !== _drag.before.y;
2233
+ } else if (_drag.type === 'rotate') {
2234
+ if (el.type === 'arrow') {
2235
+ hasMoved = el.points?.[0]?.[0] !== _drag.before.points?.[0]?.[0] || el.points?.[0]?.[1] !== _drag.before.points?.[0]?.[1];
2236
+ } else if (el.type === 'pen') {
2237
+ hasMoved = el.x !== _drag.before.x || el.y !== _drag.before.y;
2238
+ } else {
2239
+ hasMoved = (el.rotation || 0) !== (_drag.before.rotation || 0);
2240
+ }
2241
+ } else {
2242
+ hasMoved = el.x !== _drag.before.x || el.y !== _drag.before.y
2243
+ || el.width !== _drag.before.width || el.height !== _drag.before.height;
2244
+ }
2245
+ if (hasMoved) {
2246
+ const after = { ...el };
2247
+ if (el.points) after.points = el.points.map(p => [...p]);
2248
+ pushUndo('move', _drag.before, after);
2249
+ }
2250
+ }
2251
+ _drag = null;
2252
+ persistDebounced();
2253
+ }
2254
+ }
2255
+
2256
+ function onDblClick(e) {
2257
+ if (!_root || !_root.closest('#static-bg.visible')) return;
2258
+ if (e.target.closest('#wb-toolbar')) return;
2259
+
2260
+ // Double-click text → edit
2261
+ const textEl = e.target.closest('.wb-text[data-wb-id]');
2262
+ if (textEl && _activeTool === 'select') {
2263
+ const id = textEl.dataset.wbId;
2264
+ selectElement(id);
2265
+ enterEditMode(textEl, id);
2266
+ e.preventDefault();
2267
+ return;
2268
+ }
2269
+
2270
+ // Double-click list → edit
2271
+ const listEl = e.target.closest('.wb-list[data-wb-id]');
2272
+ if (listEl && _activeTool === 'select') {
2273
+ const id = listEl.dataset.wbId;
2274
+ selectElement(id);
2275
+ enterListEditMode(listEl, id);
2276
+ e.preventDefault();
2277
+ return;
2278
+ }
2279
+
2280
+ // Double-click shape → cycle subtype: rect → pill → circle → triangle → drawn-circle
2281
+ const shapeEl = e.target.closest('.wb-shape[data-wb-id]');
2282
+ if (shapeEl && _activeTool === 'select') {
2283
+ const id = shapeEl.dataset.wbId;
2284
+ const el = _elements.find(el2 => el2.id === id);
2285
+ if (el) {
2286
+ const before = { ...el };
2287
+ const cycle = { rect: 'pill', pill: 'circle', circle: 'triangle', triangle: 'drawn-circle', 'drawn-circle': 'rect' };
2288
+ el.shape = cycle[el.shape || 'rect'] || 'rect';
2289
+ // Circle → enforce square aspect ratio
2290
+ if (el.shape === 'circle') {
2291
+ const size = Math.max(el.width, el.height);
2292
+ el.width = size;
2293
+ el.height = size;
2294
+ }
2295
+ pushUndo('edit', before, { ...el });
2296
+ renderElements();
2297
+ persistDebounced();
2298
+ }
2299
+ e.preventDefault();
2300
+ return;
2301
+ }
2302
+
2303
+ // Double-click section → edit label
2304
+ const sectionEl = e.target.closest('.wb-section[data-wb-id]');
2305
+ if (sectionEl && _activeTool === 'select') {
2306
+ const id = sectionEl.dataset.wbId;
2307
+ const el = _elements.find(el2 => el2.id === id);
2308
+ if (el) {
2309
+ const labelDiv = sectionEl.querySelector('.wb-section-label');
2310
+ if (labelDiv) {
2311
+ const before = { ...el };
2312
+ labelDiv.contentEditable = 'true';
2313
+ labelDiv.focus();
2314
+ const range = document.createRange();
2315
+ range.selectNodeContents(labelDiv);
2316
+ window.getSelection().removeAllRanges();
2317
+ window.getSelection().addRange(range);
2318
+ const onBlur = () => {
2319
+ labelDiv.contentEditable = 'false';
2320
+ el.label = labelDiv.textContent.trim() || (SECTION_TYPES[el.sectionType]?.label || 'Section');
2321
+ pushUndo('edit', before, { ...el });
2322
+ renderAll();
2323
+ persistDebounced();
2324
+ labelDiv.removeEventListener('blur', onBlur);
2325
+ };
2326
+ labelDiv.addEventListener('blur', onBlur);
2327
+ labelDiv.addEventListener('keydown', (ke) => {
2328
+ if (ke.key === 'Enter') { ke.preventDefault(); labelDiv.blur(); }
2329
+ if (ke.key === 'Escape') { labelDiv.textContent = before.label || ''; labelDiv.blur(); }
2330
+ });
2331
+ }
2332
+ }
2333
+ e.preventDefault();
2334
+ return;
2335
+ }
2336
+ }
2337
+
2338
+
2339
+ // ═══════════════════════════════════════════
2340
+ // EVENT HANDLERS — KEYBOARD
2341
+ // ═══════════════════════════════════════════
2342
+
2343
+ function isWhiteboardActive() {
2344
+ const bg = $('static-bg');
2345
+ return bg && bg.classList.contains('visible');
2346
+ }
2347
+
2348
+ function onKeyDown(e) {
2349
+ if (!isWhiteboardActive()) return;
2350
+
2351
+ // Don't intercept keys when focus is in inputs outside the whiteboard (e.g. code editor)
2352
+ const tag = document.activeElement?.tagName;
2353
+ if ((tag === 'INPUT' || tag === 'TEXTAREA') && !document.activeElement.closest('#static-bg')) return;
2354
+
2355
+ const isEditingText = !!document.activeElement?.closest('.wb-text.editing');
2356
+ const editingListLi = document.activeElement?.closest('.wb-list.editing li') || null;
2357
+ const isEditing = isEditingText || !!editingListLi;
2358
+
2359
+ // List Enter → insert new item
2360
+ if (editingListLi && e.key === 'Enter') {
2361
+ e.preventDefault();
2362
+ e.stopImmediatePropagation();
2363
+ const newLi = document.createElement('li');
2364
+ newLi.setAttribute('contenteditable', 'true');
2365
+ editingListLi.after(newLi);
2366
+ newLi.focus();
2367
+ return;
2368
+ }
2369
+
2370
+ // List Backspace on empty item → remove item (keep at least 1)
2371
+ if (editingListLi && e.key === 'Backspace' && editingListLi.textContent === '') {
2372
+ const ul = editingListLi.parentElement;
2373
+ if (ul && ul.children.length > 1) {
2374
+ e.preventDefault();
2375
+ e.stopImmediatePropagation();
2376
+ const prev = editingListLi.previousElementSibling;
2377
+ const next = editingListLi.nextElementSibling;
2378
+ editingListLi.remove();
2379
+ const focusTarget = prev || next;
2380
+ if (focusTarget) {
2381
+ focusTarget.focus();
2382
+ const range = document.createRange();
2383
+ range.selectNodeContents(focusTarget);
2384
+ range.collapse(false);
2385
+ window.getSelection().removeAllRanges();
2386
+ window.getSelection().addRange(range);
2387
+ }
2388
+ }
2389
+ return;
2390
+ }
2391
+
2392
+ if (!isEditing) {
2393
+ // M → toggle multi-select mode
2394
+ if (e.key === 'm' || e.key === 'M') {
2395
+ toggleMultiSelectMode(!_multiSelectMode);
2396
+ e.stopImmediatePropagation();
2397
+ e.preventDefault();
2398
+ return;
2399
+ }
2400
+ if ((e.key === 'Delete' || e.key === 'Backspace') && _selectedIds.size > 0) {
2401
+ if (_selectedIds.size > 1) {
2402
+ deleteMultipleElements([..._selectedIds]);
2403
+ } else {
2404
+ deleteElement(_selectedId);
2405
+ }
2406
+ e.stopImmediatePropagation();
2407
+ e.preventDefault();
2408
+ return;
2409
+ }
2410
+ if (e.key === 'Enter' && _arrowCreating && _arrowCreating.points.length >= 2) {
2411
+ finalizeArrow();
2412
+ e.stopImmediatePropagation();
2413
+ e.preventDefault();
2414
+ return;
2415
+ }
2416
+ if (e.key === 'Escape') {
2417
+ if (_shapeCreating) {
2418
+ _elements = _elements.filter(el => el.id !== _shapeCreating.id);
2419
+ _shapeCreating = null;
2420
+ renderAll();
2421
+ }
2422
+ if (_sectionCreating) {
2423
+ _elements = _elements.filter(el => el.id !== _sectionCreating.id);
2424
+ _sectionCreating = null;
2425
+ renderAll();
2426
+ }
2427
+ if (_arrowCreating) { _arrowCreating = null; clearArrowPreview(); }
2428
+ if (_multiSelectMode) toggleMultiSelectMode(false);
2429
+ exitEditMode();
2430
+ exitListEditMode();
2431
+ deselectAll();
2432
+ setTool('select');
2433
+ e.stopImmediatePropagation();
2434
+ e.preventDefault();
2435
+ return;
2436
+ }
2437
+ }
2438
+
2439
+ // Escape from text editing
2440
+ if (isEditingText && e.key === 'Escape') {
2441
+ exitEditMode();
2442
+ e.stopImmediatePropagation();
2443
+ e.preventDefault();
2444
+ return;
2445
+ }
2446
+
2447
+ // Escape from list editing
2448
+ if (editingListLi && e.key === 'Escape') {
2449
+ exitListEditMode();
2450
+ e.stopImmediatePropagation();
2451
+ e.preventDefault();
2452
+ return;
2453
+ }
2454
+
2455
+ // Ctrl combos
2456
+ if (e.ctrlKey || e.metaKey) {
2457
+ if (e.key === 'z' && !e.shiftKey) {
2458
+ undo();
2459
+ e.stopImmediatePropagation();
2460
+ e.preventDefault();
2461
+ return;
2462
+ }
2463
+ if ((e.key === 'Z' && e.shiftKey) || (e.key === 'y')) {
2464
+ redo();
2465
+ e.stopImmediatePropagation();
2466
+ e.preventDefault();
2467
+ return;
2468
+ }
2469
+ if (e.key === 'a' && !isEditing) {
2470
+ // Ctrl+A → select all elements
2471
+ const allIds = _elements.map(el => el.id);
2472
+ if (allIds.length) selectMultiple(allIds);
2473
+ e.stopImmediatePropagation();
2474
+ e.preventDefault();
2475
+ return;
2476
+ }
2477
+ if (e.key === 'c' && !isEditing && _selectedIds.size > 0) {
2478
+ if (_selectedIds.size === 1) {
2479
+ const el = _elements.find(el2 => el2.id === _selectedId);
2480
+ if (el) _clipboard = { ...el };
2481
+ } else {
2482
+ // Multi-copy: store array
2483
+ _clipboard = [..._selectedIds].map(id => {
2484
+ const el = _elements.find(el2 => el2.id === id);
2485
+ return el ? { ...el, points: el.points ? el.points.map(p => [...p]) : undefined } : null;
2486
+ }).filter(Boolean);
2487
+ }
2488
+ e.stopImmediatePropagation();
2489
+ e.preventDefault();
2490
+ return;
2491
+ }
2492
+ }
2493
+ }
2494
+
2495
+
2496
+ // ═══════════════════════════════════════════
2497
+ // EVENT HANDLERS — PASTE
2498
+ // ═══════════════════════════════════════════
2499
+
2500
+ async function onPaste(e) {
2501
+ if (!isWhiteboardActive()) return;
2502
+ // Only process paste when whiteboard (or its children) has focus
2503
+ const active = document.activeElement;
2504
+ if (active && active !== _root && !_root.contains(active)) return;
2505
+ if (active?.closest('.wb-text.editing')) return;
2506
+ if (active?.closest('.wb-list.editing')) return;
2507
+
2508
+ const items = e.clipboardData?.items;
2509
+ if (!items) return;
2510
+
2511
+ // Check for image
2512
+ for (const item of items) {
2513
+ if (item.type.startsWith('image/')) {
2514
+ e.preventDefault();
2515
+ e.stopImmediatePropagation();
2516
+ const blob = item.getAsFile();
2517
+ if (!blob) return;
2518
+
2519
+ try {
2520
+ const { dataUrl, width, height } = await compressImage(blob);
2521
+ const rect = _root.getBoundingClientRect();
2522
+ const cx = rect.width / 2;
2523
+ const cy = rect.height / 2;
2524
+ const dispW = Math.min(width, 400);
2525
+ const dispH = Math.round(height * (dispW / width));
2526
+
2527
+ const el = addElement({
2528
+ id: _genId(),
2529
+ type: 'image',
2530
+ x: cx - dispW / 2,
2531
+ y: cy - dispH / 2,
2532
+ width: dispW,
2533
+ height: dispH,
2534
+ dataUrl,
2535
+ });
2536
+ selectElement(el.id);
2537
+ } catch (err) {
2538
+ console.error('[whiteboard] Image paste failed:', err);
2539
+ }
2540
+ return;
2541
+ }
2542
+ }
2543
+
2544
+ // No image — try pasting copied element(s)
2545
+ if (_clipboard) {
2546
+ e.preventDefault();
2547
+ e.stopImmediatePropagation();
2548
+ if (Array.isArray(_clipboard)) {
2549
+ // Multi-paste
2550
+ const newIds = [];
2551
+ const updated = _clipboard.map(src => {
2552
+ const clone = { ...src, id: _genId(), x: (src.x || 0) + 20, y: (src.y || 0) + 20 };
2553
+ delete clone.zIndex;
2554
+ clone.startAnchor = null;
2555
+ clone.endAnchor = null;
2556
+ if (clone.points) clone.points = clone.points.map(([px, py]) => [px + 20, py + 20]);
2557
+ if (clone.points && clone.type === 'pen') clone.pathD = pointsToSmoothPath(clone.points);
2558
+ const added = addElement(clone);
2559
+ newIds.push(added.id);
2560
+ return { ...clone };
2561
+ });
2562
+ selectMultiple(newIds);
2563
+ _clipboard = updated;
2564
+ } else {
2565
+ const clone = { ..._clipboard, id: _genId(), x: (_clipboard.x || 0) + 20, y: (_clipboard.y || 0) + 20 };
2566
+ delete clone.zIndex;
2567
+ if (clone.type === 'arrow') {
2568
+ clone.startAnchor = null;
2569
+ clone.endAnchor = null;
2570
+ if (clone.points) clone.points = clone.points.map(([px, py]) => [px + 20, py + 20]);
2571
+ }
2572
+ const el = addElement(clone);
2573
+ selectElement(el.id);
2574
+ _clipboard = { ...clone };
2575
+ }
2576
+ }
2577
+ }
2578
+
2579
+
2580
+ // ═══════════════════════════════════════════
2581
+ // COLOR PICKER
2582
+ // ═══════════════════════════════════════════
2583
+
2584
+ function initColorPicker() {
2585
+ const picker = $('wb-color-picker');
2586
+ const dot = $('wb-color-dot');
2587
+ const btn = $('wb-color-btn');
2588
+ if (!picker || !dot || !btn) return;
2589
+
2590
+ // Build swatches
2591
+ picker.innerHTML = WB_COLORS.map(c =>
2592
+ `<div class="wb-color-swatch${c === _activeColor ? ' active' : ''}" data-color="${c}" style="background:${c}"></div>`
2593
+ ).join('');
2594
+
2595
+ // Toggle picker on button click
2596
+ btn.addEventListener('click', (e) => {
2597
+ e.stopPropagation();
2598
+ const open = picker.style.display !== 'none';
2599
+ picker.style.display = open ? 'none' : 'grid';
2600
+ if (!open) {
2601
+ // Position next to the color button
2602
+ const btnRect = btn.getBoundingClientRect();
2603
+ const toolbarRect = _toolbar.getBoundingClientRect();
2604
+ picker.style.top = (btnRect.top - toolbarRect.top) + 'px';
2605
+ }
2606
+ });
2607
+
2608
+ // Select color
2609
+ picker.addEventListener('click', (e) => {
2610
+ const swatch = e.target.closest('.wb-color-swatch');
2611
+ if (!swatch) return;
2612
+ _activeColor = swatch.dataset.color;
2613
+ dot.style.background = _activeColor;
2614
+ picker.querySelectorAll('.wb-color-swatch').forEach(s => s.classList.remove('active'));
2615
+ swatch.classList.add('active');
2616
+ picker.style.display = 'none';
2617
+
2618
+ // Update selected element(s) color
2619
+ if (_selectedIds.size > 0) {
2620
+ for (const id of _selectedIds) {
2621
+ const el = _elements.find(e2 => e2.id === id);
2622
+ if (el) el.color = _activeColor;
2623
+ }
2624
+ renderAll();
2625
+ persistDebounced();
2626
+ }
2627
+ });
2628
+
2629
+ // Close picker on outside click
2630
+ document.addEventListener('mousedown', (e) => {
2631
+ if (!e.target.closest('#wb-color-picker') && !e.target.closest('#wb-color-btn')) {
2632
+ picker.style.display = 'none';
2633
+ }
2634
+ });
2635
+ }
2636
+
2637
+ function initSectionPicker() {
2638
+ const picker = document.getElementById('wb-section-picker');
2639
+ const btn = _toolbar?.querySelector('[data-tool="section"]');
2640
+ if (!picker || !btn) return;
2641
+
2642
+ picker.innerHTML = Object.entries(SECTION_TYPES).map(([key, def]) =>
2643
+ `<button class="wb-section-btn${key === _activeSectionType ? ' active' : ''}" data-section="${key}">
2644
+ <span class="wb-section-icon" style="color:${def.color}">${def.icon}</span>
2645
+ <span class="wb-section-label-text">${def.label}</span>
2646
+ </button>`
2647
+ ).join('');
2648
+
2649
+ picker.addEventListener('click', (e2) => {
2650
+ const sBtn = e2.target.closest('.wb-section-btn');
2651
+ if (!sBtn) return;
2652
+ _activeSectionType = sBtn.dataset.section;
2653
+ picker.querySelectorAll('.wb-section-btn').forEach(s => s.classList.remove('active'));
2654
+ sBtn.classList.add('active');
2655
+ });
2656
+
2657
+ document.addEventListener('mousedown', (e2) => {
2658
+ if (!e2.target.closest('#wb-section-picker') && !e2.target.closest('[data-tool="section"]')) {
2659
+ picker.style.display = 'none';
2660
+ }
2661
+ });
2662
+ }
2663
+
2664
+ const SHAPE_TYPES = [
2665
+ { key: 'rect', label: 'Rectangle', icon: '<svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/></svg>' },
2666
+ { key: 'circle', label: 'Circle', icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>' },
2667
+ { key: 'triangle', label: 'Triangle', icon: '<svg viewBox="0 0 24 24"><path d="M12 4L22 20H2Z"/></svg>' },
2668
+ ];
2669
+
2670
+ function initShapePicker() {
2671
+ const picker = document.getElementById('wb-shape-picker');
2672
+ const btn = _toolbar?.querySelector('[data-tool="shape"]');
2673
+ if (!picker || !btn) return;
2674
+
2675
+ picker.innerHTML = SHAPE_TYPES.map(s =>
2676
+ `<button class="wb-shape-btn${s.key === _activeShapeType ? ' active' : ''}" data-shape="${s.key}">
2677
+ ${s.icon}
2678
+ <span>${s.label}</span>
2679
+ </button>`
2680
+ ).join('');
2681
+
2682
+ picker.addEventListener('click', (e2) => {
2683
+ const sBtn = e2.target.closest('.wb-shape-btn');
2684
+ if (!sBtn) return;
2685
+ _activeShapeType = sBtn.dataset.shape;
2686
+ picker.querySelectorAll('.wb-shape-btn').forEach(s => s.classList.remove('active'));
2687
+ sBtn.classList.add('active');
2688
+ });
2689
+
2690
+ document.addEventListener('mousedown', (e2) => {
2691
+ if (!e2.target.closest('#wb-shape-picker') && !e2.target.closest('[data-tool="shape"]')) {
2692
+ picker.style.display = 'none';
2693
+ }
2694
+ });
2695
+ }
2696
+
2697
+ // ═══════════════════════════════════════════
2698
+ // TOOLBAR HANDLERS
2699
+ // ═══════════════════════════════════════════
2700
+
2701
+ function onToolbarClick(e) {
2702
+ // Don't handle color picker clicks as tool clicks
2703
+ if (e.target.closest('#wb-color-btn') || e.target.closest('#wb-color-picker')) return;
2704
+
2705
+ const btn = e.target.closest('.wb-tool');
2706
+ if (!btn) return;
2707
+
2708
+ // Block drawing tools for guests without whiteboard permission
2709
+ if (isGuest() && !hasPermission('whiteboard') && btn.dataset.tool && btn.dataset.tool !== 'select') return;
2710
+
2711
+ if (btn.dataset.tool) {
2712
+ setTool(btn.dataset.tool);
2713
+ if (_multiSelectMode) toggleMultiSelectMode(false);
2714
+ return;
2715
+ }
2716
+
2717
+ const _wbGuestRO = isGuest() && !hasPermission('whiteboard');
2718
+
2719
+ if (btn.id === 'wb-multiselect') {
2720
+ if (_wbGuestRO) return;
2721
+ toggleMultiSelectMode(!_multiSelectMode);
2722
+ return;
2723
+ }
2724
+ if (btn.id === 'wb-undo') { if (!_wbGuestRO) undo(); return; }
2725
+ if (btn.id === 'wb-redo') { if (!_wbGuestRO) redo(); return; }
2726
+ if (btn.id === 'wb-delete' && _selectedIds.size > 0) {
2727
+ if (_wbGuestRO) return;
2728
+ if (_selectedIds.size > 1) deleteMultipleElements([..._selectedIds]);
2729
+ else deleteElement(_selectedId);
2730
+ return;
2731
+ }
2732
+ if (btn.id === 'wb-toggle-logo') {
2733
+ const logo = document.querySelector('#static-bg .static-bg-logo');
2734
+ const breathe = document.querySelector('#static-bg .focus-breathe');
2735
+ const hidden = logo?.style.display !== 'none';
2736
+ if (logo) logo.style.display = hidden ? 'none' : '';
2737
+ if (breathe) breathe.style.display = hidden ? 'none' : '';
2738
+ btn.classList.toggle('active', hidden);
2739
+ sessionStorage.setItem('wb-logo-hidden', hidden ? '1' : '');
2740
+ return;
2741
+ }
2742
+ }
2743
+
2744
+
2745
+ // ═══════════════════════════════════════════
2746
+ // PUBLIC API — WORKSPACE SNAPSHOTS
2747
+ // ═══════════════════════════════════════════
2748
+
2749
+ export function getWhiteboardElementById(id) {
2750
+ return _elements.find(e => e.id === id) || null;
2751
+ }
2752
+
2753
+ export function getWhiteboardSnapshot() {
2754
+ // Flush any in-progress edits so content is captured
2755
+ flushTextEdits();
2756
+ flushListEdits();
2757
+ return {
2758
+ elements: _elements.map(el => {
2759
+ const copy = { ...el };
2760
+ if (copy.points) copy.points = copy.points.map(p => [...p]);
2761
+ return copy;
2762
+ }),
2763
+ nextZIndex: _nextZIndex,
2764
+ };
2765
+ }
2766
+
2767
+ export function restoreWhiteboardSnapshot(snap) {
2768
+ clearTimeout(_persistTimer);
2769
+
2770
+ if (!snap) {
2771
+ clearWhiteboard();
2772
+ return;
2773
+ }
2774
+ _elements = (snap.elements || []).map(el => {
2775
+ // Migrate legacy arrow format (startX/endX → points)
2776
+ if (el.type === 'arrow' && !el.points && el.startX != null) {
2777
+ el.points = [[el.startX, el.startY], [el.endX, el.endY]];
2778
+ delete el.startX; delete el.startY; delete el.endX; delete el.endY;
2779
+ delete el.curvature;
2780
+ }
2781
+ return { ...el };
2782
+ });
2783
+ _nextZIndex = snap.nextZIndex || (_elements.length + 1);
2784
+ _undoStack = [];
2785
+ _redoStack = [];
2786
+ _selectedId = null;
2787
+ _selectedIds.clear();
2788
+ _arrowCreating = null;
2789
+ _sectionCreating = null;
2790
+ _clipboard = null;
2791
+ renderAll();
2792
+ persistDebounced();
2793
+ }
2794
+
2795
+ export function clearWhiteboard() {
2796
+ _elements = [];
2797
+ _nextZIndex = 1;
2798
+ _undoStack = [];
2799
+ _redoStack = [];
2800
+ _selectedId = null;
2801
+ _selectedIds.clear();
2802
+ _arrowCreating = null;
2803
+ _sectionCreating = null;
2804
+ _clipboard = null;
2805
+ renderAll();
2806
+ persistDebounced();
2807
+ }
2808
+
2809
+
2810
+ export function addImageToWhiteboard(url) {
2811
+ const img = new Image();
2812
+ img.crossOrigin = 'anonymous';
2813
+ img.onload = () => {
2814
+ const canvas = document.createElement('canvas');
2815
+ canvas.width = img.width;
2816
+ canvas.height = img.height;
2817
+ canvas.getContext('2d').drawImage(img, 0, 0);
2818
+ const dataUrl = canvas.toDataURL('image/png');
2819
+ const rect = _root?.getBoundingClientRect();
2820
+ const vw = rect?.width || 1200;
2821
+ const vh = rect?.height || 800;
2822
+ const dispW = Math.min(img.width, 400);
2823
+ const dispH = Math.round(img.height * (dispW / img.width));
2824
+ addElement({
2825
+ id: _genId(),
2826
+ type: 'image',
2827
+ x: (vw - dispW) / 2,
2828
+ y: (vh - dispH) / 2,
2829
+ width: dispW,
2830
+ height: dispH,
2831
+ dataUrl,
2832
+ });
2833
+ };
2834
+ img.src = url;
2835
+ }
2836
+
2837
+ // ═══════════════════════════════════════════
2838
+ // INIT
2839
+ // ═══════════════════════════════════════════
2840
+
2841
+ export function initWhiteboard() {
2842
+ _root = $('wb-root');
2843
+ _toolbar = $('wb-toolbar');
2844
+ _canvas = $('wb-canvas');
2845
+ _arrowsSvg = $('wb-arrows');
2846
+ _elementsDiv = $('wb-elements');
2847
+ _arrowPreview = $('wb-arrow-preview');
2848
+
2849
+ // Create arrow creation hint element
2850
+ _arrowHint = document.createElement('div');
2851
+ _arrowHint.className = 'wb-arrow-hint';
2852
+ _arrowHint.innerHTML = 'click to add points &middot; <kbd>dbl-click</kbd> or <kbd>Enter</kbd> to finish';
2853
+
2854
+ if (!_root) {
2855
+ console.warn('[whiteboard] #wb-root not found, skipping init');
2856
+ return;
2857
+ }
2858
+
2859
+ // Make focusable so paste events work (and clicks steal focus from terminal)
2860
+ _root.setAttribute('tabindex', '-1');
2861
+ _root.style.outline = 'none';
2862
+
2863
+ _root.appendChild(_arrowHint);
2864
+
2865
+ // Load persisted state
2866
+ loadPersisted();
2867
+ renderAll();
2868
+ updateUndoButtons();
2869
+
2870
+ // Mouse events
2871
+ _root.addEventListener('mousedown', onMouseDown);
2872
+ window.addEventListener('mousemove', onMouseMove);
2873
+ window.addEventListener('mouseup', onMouseUp);
2874
+ _root.addEventListener('dblclick', onDblClick);
2875
+
2876
+ // Keyboard — capture phase so we fire before global keybinds
2877
+ document.addEventListener('keydown', onKeyDown, true);
2878
+
2879
+ // Track Ctrl key for tool lock
2880
+ document.addEventListener('keydown', onCtrlTrack);
2881
+ document.addEventListener('keyup', onCtrlTrack);
2882
+ window.addEventListener('blur', () => { _toolLocked = false; updateLockIndicator(); });
2883
+
2884
+ // Paste — listen on document (not _root) because _root can't receive focus
2885
+ // and paste events only fire on the focused element's ancestor chain.
2886
+ // The isWhiteboardActive() guard in onPaste prevents firing outside focus mode.
2887
+ document.addEventListener('paste', onPaste);
2888
+
2889
+ // Toolbar
2890
+ if (_toolbar) _toolbar.addEventListener('click', onToolbarClick);
2891
+
2892
+ // Color picker
2893
+ initColorPicker();
2894
+
2895
+ // Shape picker
2896
+ initShapePicker();
2897
+
2898
+ // Section picker
2899
+ initSectionPicker();
2900
+
2901
+ // Prevent context menu on whiteboard
2902
+ _root.addEventListener('contextmenu', e => e.preventDefault());
2903
+
2904
+ // Listen for workspace events
2905
+ on('whiteboard:restore', restoreWhiteboardSnapshot);
2906
+ on('whiteboard:clear', clearWhiteboard);
2907
+
2908
+ // Auto-focus whiteboard when entering focus mode so Ctrl+V paste works immediately
2909
+ on('focus:enter', () => {
2910
+ setTimeout(() => _root.focus({ preventScroll: true }), 150);
2911
+ // Restore logo hidden state from session
2912
+ if (sessionStorage.getItem('wb-logo-hidden') === '1') {
2913
+ const logo = document.querySelector('#static-bg .static-bg-logo');
2914
+ const breathe = document.querySelector('#static-bg .focus-breathe');
2915
+ if (logo) logo.style.display = 'none';
2916
+ if (breathe) breathe.style.display = 'none';
2917
+ const btn = document.getElementById('wb-toggle-logo');
2918
+ if (btn) btn.classList.add('active');
2919
+ }
2920
+ });
2921
+
2922
+ // Connect to server for external commands (Claude MCP)
2923
+ _connectWhiteboardWS();
2924
+
2925
+ // ── Guest permission: disable drawing tools when whiteboard perm is off ──
2926
+ function updateWbPermVisual() {
2927
+ if (!_toolbar || !_canvas) return;
2928
+ const blocked = isGuest() && !hasPermission('whiteboard');
2929
+ // Disable all tool buttons except select
2930
+ _toolbar.querySelectorAll('.wb-tool[data-tool]').forEach(btn => {
2931
+ if (btn.dataset.tool === 'select') return;
2932
+ btn.style.opacity = blocked ? '0.35' : '';
2933
+ btn.style.pointerEvents = blocked ? 'none' : '';
2934
+ });
2935
+ // Disable multi-select, undo, redo, delete buttons
2936
+ ['wb-multiselect', 'wb-undo', 'wb-redo', 'wb-delete', 'wb-color-btn'].forEach(id => {
2937
+ const btn = $(id);
2938
+ if (btn) {
2939
+ btn.style.opacity = blocked ? '0.35' : '';
2940
+ btn.style.pointerEvents = blocked ? 'none' : '';
2941
+ }
2942
+ });
2943
+ // Block canvas interaction for drawing (keep pointer events for panning/viewing)
2944
+ if (blocked) {
2945
+ _canvas.style.cursor = 'default';
2946
+ setTool('select');
2947
+ if (_multiSelectMode) toggleMultiSelectMode(false);
2948
+ }
2949
+ }
2950
+ on('session:info', updateWbPermVisual);
2951
+ on('permissions:changed', updateWbPermVisual);
2952
+ }
2953
+
2954
+
2955
+ // ═══════════════════════════════════════════
2956
+ // EXTERNAL COMMAND CHANNEL (WebSocket — Claude MCP ↔ Whiteboard)
2957
+ // ═══════════════════════════════════════════
2958
+
2959
+ let _ws = null;
2960
+ let _wsReconnectTimer = null;
2961
+
2962
+ function _connectWhiteboardWS() {
2963
+ if (_ws && _ws.readyState <= 1) return; // CONNECTING or OPEN
2964
+
2965
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
2966
+ _ws = new WebSocket(`${protocol}//${location.host}/ws/whiteboard`);
2967
+
2968
+ _ws.onopen = () => {
2969
+ console.log('[whiteboard-ws] Connected');
2970
+ if (_wsReconnectTimer) { clearInterval(_wsReconnectTimer); _wsReconnectTimer = null; }
2971
+ // Report usable viewport dimensions (excluding navbar, toolbar, terminal) for MCP coordinate mapping
2972
+ if (_root) {
2973
+ const rect = _root.getBoundingClientRect();
2974
+ const titleBar = document.getElementById('title-bar');
2975
+ const toolbar = document.getElementById('wb-toolbar');
2976
+ const navH = titleBar ? titleBar.getBoundingClientRect().height : 0;
2977
+ const tbW = toolbar ? toolbar.getBoundingClientRect().right - rect.left + 12 : 60;
2978
+ _ws.send(JSON.stringify({ type: 'viewport', width: Math.round(rect.width - tbW), height: Math.round(rect.height - navH), yOffset: Math.round(navH), xOffset: Math.round(tbW) }));
2979
+ }
2980
+ };
2981
+
2982
+ _ws.onmessage = (event) => {
2983
+ try {
2984
+ const msg = JSON.parse(event.data);
2985
+ _handleExternalCommand(msg);
2986
+ } catch { /* ignore malformed */ }
2987
+ };
2988
+
2989
+ _ws.onclose = () => {
2990
+ console.log('[whiteboard-ws] Disconnected, will reconnect in 5s');
2991
+ _ws = null;
2992
+ if (!_wsReconnectTimer) {
2993
+ _wsReconnectTimer = setInterval(_connectWhiteboardWS, 5000);
2994
+ }
2995
+ };
2996
+
2997
+ _ws.onerror = () => {}; // onclose fires after
2998
+ }
2999
+
3000
+ // Debounced viewport resize reporting
3001
+ let _viewportResizeTimer = null;
3002
+ window.addEventListener('resize', () => {
3003
+ clearTimeout(_viewportResizeTimer);
3004
+ _viewportResizeTimer = setTimeout(() => {
3005
+ if (_ws && _ws.readyState === 1 && _root) {
3006
+ const rect = _root.getBoundingClientRect();
3007
+ const titleBar = document.getElementById('title-bar');
3008
+ const toolbar = document.getElementById('wb-toolbar');
3009
+ const navH = titleBar ? titleBar.getBoundingClientRect().height : 0;
3010
+ const tbW = toolbar ? toolbar.getBoundingClientRect().right - rect.left + 12 : 60;
3011
+ _ws.send(JSON.stringify({ type: 'viewport', width: Math.round(rect.width - tbW), height: Math.round(rect.height - navH), yOffset: Math.round(navH), xOffset: Math.round(tbW) }));
3012
+ }
3013
+ }, 300);
3014
+ });
3015
+
3016
+ function _handleExternalCommand(msg) {
3017
+ switch (msg.type) {
3018
+ case 'init':
3019
+ break;
3020
+
3021
+ case 'add':
3022
+ for (const el of (msg.elements || [])) {
3023
+ addElement(el, { skipPersist: true });
3024
+ }
3025
+ break;
3026
+
3027
+ case 'update': {
3028
+ const el = _elements.find(e => e.id === msg.id);
3029
+ if (!el) break;
3030
+ const before = { ...el, points: el.points ? el.points.map(p => [...p]) : undefined };
3031
+ for (const [key, value] of Object.entries(msg.updates || {})) {
3032
+ if (key === 'id' || key === 'type') continue;
3033
+ el[key] = value;
3034
+ }
3035
+ pushUndo('edit', before, { ...el, points: el.points ? el.points.map(p => [...p]) : undefined });
3036
+ renderAll();
3037
+ break;
3038
+ }
3039
+
3040
+ case 'remove': {
3041
+ const idx = _elements.findIndex(e => e.id === msg.id);
3042
+ if (idx < 0) break;
3043
+ const el = { ..._elements[idx] };
3044
+ _elements.splice(idx, 1);
3045
+ pushUndo('delete', el, null);
3046
+ _selectedIds.delete(msg.id);
3047
+ if (_selectedId === msg.id) _selectedId = null;
3048
+ renderAll();
3049
+ break;
3050
+ }
3051
+
3052
+ case 'clear':
3053
+ _elements = [];
3054
+ _nextZIndex = 1;
3055
+ _undoStack = [];
3056
+ _redoStack = [];
3057
+ _selectedId = null;
3058
+ _selectedIds.clear();
3059
+ _arrowCreating = null;
3060
+ _sectionCreating = null;
3061
+ renderAll();
3062
+ break;
3063
+
3064
+ case 'state:full':
3065
+ // Full state sync from another client — replace local state, save to localStorage only (no WS echo)
3066
+ _elements = msg.snapshot?.elements || [];
3067
+ _nextZIndex = msg.snapshot?.nextZIndex || 1;
3068
+ _selectedId = null;
3069
+ _selectedIds.clear();
3070
+ renderAll();
3071
+ storage.setItem(KEYS.WHITEBOARD, JSON.stringify({ elements: _elements, nextZIndex: _nextZIndex }));
3072
+ break;
3073
+
3074
+ case 'screenshot:request':
3075
+ _captureScreenshot(msg.requestId);
3076
+ break;
3077
+
3078
+ case 'screenshot:auto': {
3079
+ // macOS screenshot auto-paste — add image centered in viewport
3080
+ if (!msg.dataUrl) break;
3081
+ const img = new Image();
3082
+ img.onload = () => {
3083
+ const rect = _root?.getBoundingClientRect();
3084
+ const vw = rect?.width || 1200;
3085
+ const vh = rect?.height || 800;
3086
+ const dispW = Math.min(img.width, 400);
3087
+ const dispH = Math.round(img.height * (dispW / img.width));
3088
+ addElement({
3089
+ id: _genId(),
3090
+ type: 'image',
3091
+ x: (vw - dispW) / 2,
3092
+ y: (vh - dispH) / 2,
3093
+ width: dispW,
3094
+ height: dispH,
3095
+ dataUrl: msg.dataUrl,
3096
+ });
3097
+ };
3098
+ img.src = msg.dataUrl;
3099
+ break;
3100
+ }
3101
+ }
3102
+ }
3103
+
3104
+ // ── Screenshot capture (renders whiteboard to canvas for Claude) ──
3105
+
3106
+ function _loadImage(src) {
3107
+ return new Promise((resolve, reject) => {
3108
+ const img = new Image();
3109
+ img.onload = () => resolve(img);
3110
+ img.onerror = reject;
3111
+ img.src = src;
3112
+ });
3113
+ }
3114
+
3115
+ function _drawArrowhead(ctx, points) {
3116
+ if (points.length < 2) return;
3117
+ const last = points[points.length - 1];
3118
+ const prev = points[points.length - 2];
3119
+ const angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]);
3120
+ const size = 20;
3121
+ ctx.fillStyle = ctx.strokeStyle;
3122
+ ctx.beginPath();
3123
+ ctx.moveTo(last[0], last[1]);
3124
+ ctx.lineTo(last[0] - size * Math.cos(angle - Math.PI / 6), last[1] - size * Math.sin(angle - Math.PI / 6));
3125
+ ctx.lineTo(last[0] - size * Math.cos(angle + Math.PI / 6), last[1] - size * Math.sin(angle + Math.PI / 6));
3126
+ ctx.closePath();
3127
+ ctx.fill();
3128
+ }
3129
+
3130
+ async function _captureScreenshot(requestId) {
3131
+ try {
3132
+ const canvas = document.createElement('canvas');
3133
+ const rootRect = _root.getBoundingClientRect();
3134
+ canvas.width = rootRect.width;
3135
+ canvas.height = rootRect.height;
3136
+ const ctx = canvas.getContext('2d');
3137
+
3138
+ // Background
3139
+ ctx.fillStyle = '#0a0a0f';
3140
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
3141
+
3142
+ // Sort by zIndex for correct layering
3143
+ const sorted = [..._elements].sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
3144
+
3145
+ for (const el of sorted) {
3146
+ ctx.save();
3147
+
3148
+ if (el.rotation) {
3149
+ const cx = el.x + (el.width || 0) / 2;
3150
+ const cy = el.y + (el.height || 0) / 2;
3151
+ ctx.translate(cx, cy);
3152
+ ctx.rotate(el.rotation * Math.PI / 180);
3153
+ ctx.translate(-cx, -cy);
3154
+ }
3155
+
3156
+ if (el.type === 'text') {
3157
+ const weight = el.bold ? '700' : '400';
3158
+ const style = el.italic ? 'italic ' : '';
3159
+ ctx.font = `${style}${weight} ${el.fontSize || 22}px Caveat, cursive`;
3160
+ ctx.fillStyle = el.color || 'rgba(255,255,255,0.85)';
3161
+ ctx.textBaseline = 'top';
3162
+ const lines = (el.content || '').split('\n');
3163
+ let lineY = el.y + 10;
3164
+ for (const line of lines) {
3165
+ ctx.fillText(line, el.x + 14, lineY);
3166
+ lineY += (el.fontSize || 22) * 1.35;
3167
+ }
3168
+ } else if (el.type === 'list') {
3169
+ const fs = el.fontSize || 18;
3170
+ ctx.font = `400 ${fs}px JetBrains Mono, monospace`;
3171
+ ctx.fillStyle = el.color || 'rgba(255,255,255,0.85)';
3172
+ ctx.textBaseline = 'top';
3173
+ const items = el.items && el.items.length ? el.items : [''];
3174
+ let lineY = el.y + 10;
3175
+ for (const item of items) {
3176
+ ctx.fillText('• ' + item, el.x + 14, lineY);
3177
+ lineY += fs * 1.5;
3178
+ }
3179
+ } else if (el.type === 'shape') {
3180
+ ctx.strokeStyle = el.color || 'rgba(255,255,255,0.75)';
3181
+ ctx.lineWidth = 3;
3182
+ ctx.lineCap = 'round';
3183
+ ctx.lineJoin = 'round';
3184
+ const w = el.width || 160, h = el.height || 100;
3185
+ if (el.shape === 'circle' || el.shape === 'drawn-circle') {
3186
+ ctx.beginPath();
3187
+ ctx.ellipse(el.x + w / 2, el.y + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
3188
+ ctx.stroke();
3189
+ } else if (el.shape === 'pill') {
3190
+ const r = Math.min(w, h) / 2;
3191
+ ctx.beginPath();
3192
+ ctx.roundRect(el.x, el.y, w, h, r);
3193
+ ctx.stroke();
3194
+ } else {
3195
+ ctx.strokeRect(el.x, el.y, w, h);
3196
+ }
3197
+ } else if (el.type === 'image' && el.dataUrl) {
3198
+ try {
3199
+ const img = await _loadImage(el.dataUrl);
3200
+ ctx.drawImage(img, el.x, el.y, el.width, el.height);
3201
+ } catch { /* skip broken images */ }
3202
+ } else if (el.type === 'arrow' && el.points && el.points.length >= 2) {
3203
+ ctx.strokeStyle = el.color || 'rgba(255,255,255,0.7)';
3204
+ ctx.lineWidth = 3;
3205
+ ctx.lineCap = 'round';
3206
+ ctx.lineJoin = 'round';
3207
+ ctx.beginPath();
3208
+ ctx.moveTo(el.points[0][0], el.points[0][1]);
3209
+ for (let i = 1; i < el.points.length; i++) {
3210
+ ctx.lineTo(el.points[i][0], el.points[i][1]);
3211
+ }
3212
+ ctx.stroke();
3213
+ _drawArrowhead(ctx, el.points);
3214
+ } else if (el.type === 'pen' && el.points && el.points.length >= 2) {
3215
+ ctx.strokeStyle = el.color || 'rgba(255,255,255,0.7)';
3216
+ ctx.lineWidth = el.strokeWidth || 3;
3217
+ ctx.lineCap = 'round';
3218
+ ctx.lineJoin = 'round';
3219
+ const pathD = el.pathD || pointsToSmoothPath(el.points);
3220
+ const p2d = new Path2D(pathD);
3221
+ ctx.stroke(p2d);
3222
+ } else if (el.type === 'section') {
3223
+ const def = SECTION_TYPES[el.sectionType] || SECTION_TYPES.content;
3224
+ const color = el.color || def.color;
3225
+ const w = el.width || def.w;
3226
+ const h = el.height || def.h;
3227
+ ctx.strokeStyle = color;
3228
+ ctx.lineWidth = 1;
3229
+ ctx.setLineDash([6, 4]);
3230
+ ctx.strokeRect(el.x, el.y, w, h);
3231
+ ctx.setLineDash([]);
3232
+ ctx.fillStyle = color + '1a';
3233
+ ctx.fillRect(el.x, el.y, w, h);
3234
+ ctx.font = '28px sans-serif';
3235
+ ctx.fillStyle = color + '33';
3236
+ ctx.textAlign = 'center';
3237
+ ctx.textBaseline = 'middle';
3238
+ ctx.fillText(def.icon, el.x + w / 2, el.y + h / 2);
3239
+ ctx.font = '12px JetBrains Mono, monospace';
3240
+ ctx.fillStyle = color;
3241
+ ctx.textAlign = 'left';
3242
+ ctx.textBaseline = 'top';
3243
+ ctx.fillText(el.label || def.label, el.x + 8, el.y + 6);
3244
+ ctx.textAlign = 'start';
3245
+ }
3246
+
3247
+ ctx.restore();
3248
+ }
3249
+
3250
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
3251
+ const base64 = dataUrl.split(',')[1];
3252
+
3253
+ if (_ws && _ws.readyState === 1) {
3254
+ _ws.send(JSON.stringify({ type: 'screenshot:response', requestId, data: base64 }));
3255
+ }
3256
+ } catch (err) {
3257
+ console.error('[whiteboard] Screenshot capture failed:', err);
3258
+ if (_ws && _ws.readyState === 1) {
3259
+ _ws.send(JSON.stringify({ type: 'screenshot:error', requestId, error: err.message }));
3260
+ }
3261
+ }
3262
+ }