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.
- package/CHANGELOG.md +665 -0
- package/LICENSE +191 -0
- package/README.md +853 -0
- package/hooks/claude-code/post-plan.mjs +391 -0
- package/hooks/claude-code/post-remember.mjs +239 -0
- package/hooks/claude-code/pre-compact.mjs +241 -0
- package/hooks/claude-code/pre-websearch.mjs +90 -0
- package/hooks/claude-code/prompt-submit.mjs +861 -0
- package/hooks/claude-code/session-start.mjs +494 -0
- package/hooks/claude-code/shared.mjs +368 -0
- package/hooks/claude-code/stop.mjs +646 -0
- package/hooks/claude-code/test-user-learning.mjs +531 -0
- package/mcp-server/package.json +38 -0
- package/mcp-server/run.mjs +10 -0
- package/mcp-server/src/config.ts +70 -0
- package/mcp-server/src/http.ts +93 -0
- package/mcp-server/src/index.ts +203 -0
- package/mcp-server/src/preload-http.ts +25 -0
- package/mcp-server/src/preload.ts +29 -0
- package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
- package/mcp-server/src/services/categories.ts +429 -0
- package/mcp-server/src/services/discord.ts +702 -0
- package/mcp-server/src/services/file-checksums.ts +33 -0
- package/mcp-server/src/services/local-embeddings.ts +77 -0
- package/mcp-server/src/services/neural-interface.ts +344 -0
- package/mcp-server/src/services/sqlite.ts +823 -0
- package/mcp-server/src/tools/browser-advanced.ts +123 -0
- package/mcp-server/src/tools/browser-interact.ts +255 -0
- package/mcp-server/src/tools/browser-navigate.ts +77 -0
- package/mcp-server/src/tools/browser-observe.ts +1330 -0
- package/mcp-server/src/tools/browser.ts +107 -0
- package/mcp-server/src/tools/card-tools.ts +193 -0
- package/mcp-server/src/tools/card.ts +22 -0
- package/mcp-server/src/tools/category.ts +379 -0
- package/mcp-server/src/tools/discord-channel.ts +274 -0
- package/mcp-server/src/tools/discord-guild.ts +188 -0
- package/mcp-server/src/tools/discord-member.ts +158 -0
- package/mcp-server/src/tools/discord-message.ts +231 -0
- package/mcp-server/src/tools/discord-onboarding.ts +228 -0
- package/mcp-server/src/tools/discord-role.ts +209 -0
- package/mcp-server/src/tools/discord-thread.ts +110 -0
- package/mcp-server/src/tools/discord-webhook.ts +176 -0
- package/mcp-server/src/tools/discord.ts +40 -0
- package/mcp-server/src/tools/forget.ts +38 -0
- package/mcp-server/src/tools/git-tools.ts +141 -0
- package/mcp-server/src/tools/git.ts +13 -0
- package/mcp-server/src/tools/image-tools.ts +98 -0
- package/mcp-server/src/tools/image.ts +17 -0
- package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
- package/mcp-server/src/tools/leonardo.ts +28 -0
- package/mcp-server/src/tools/loop.ts +348 -0
- package/mcp-server/src/tools/memories.ts +118 -0
- package/mcp-server/src/tools/recall.ts +323 -0
- package/mcp-server/src/tools/reflect.ts +145 -0
- package/mcp-server/src/tools/remember.ts +115 -0
- package/mcp-server/src/tools/response.ts +26 -0
- package/mcp-server/src/tools/restore.ts +34 -0
- package/mcp-server/src/tools/sync.ts +114 -0
- package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
- package/mcp-server/src/tools/tictactoe.ts +13 -0
- package/mcp-server/src/tools/utils.ts +25 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
- package/mcp-server/src/tools/whiteboard.ts +22 -0
- package/mcp-server/src/tui.ts +940 -0
- package/mcp-server/src/types.ts +85 -0
- package/mcp-server/tsconfig.json +19 -0
- package/neural-interface/package.json +27 -0
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/claude.svg +1 -0
- package/neural-interface/public/category-logos/gemini-color.svg +1 -0
- package/neural-interface/public/category-logos/openai.svg +1 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude-chat.css +878 -0
- package/neural-interface/public/claude-chat.html +92 -0
- package/neural-interface/public/claude-chat.js +1168 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/gallery.svg +6 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index-legacy.html +14677 -0
- package/neural-interface/public/index.html +212 -0
- package/neural-interface/public/index2d-legacy.html +8193 -0
- package/neural-interface/public/index2d.html +127 -0
- package/neural-interface/public/invite.html +351 -0
- package/neural-interface/public/leonardoai.svg +1 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +14 -0
- package/neural-interface/public/offline.html +299 -0
- package/neural-interface/public/onboarding.html +2335 -0
- package/neural-interface/public/openclaw-logo-text.png +0 -0
- package/neural-interface/public/pin-svgrepo-com.svg +25 -0
- package/neural-interface/public/sessionicon.svg +8 -0
- package/neural-interface/public/shared/ansi-parser.js +305 -0
- package/neural-interface/public/shared/api.js +1043 -0
- package/neural-interface/public/shared/colors.js +153 -0
- package/neural-interface/public/shared/constants.js +141 -0
- package/neural-interface/public/shared/html-shell.js +777 -0
- package/neural-interface/public/shared/html-term-renderer.js +1022 -0
- package/neural-interface/public/shared/i18n.js +147 -0
- package/neural-interface/public/shared/registry.js +82 -0
- package/neural-interface/public/shared/state.js +86 -0
- package/neural-interface/public/shared/storage.js +150 -0
- package/neural-interface/public/shared/styles.css +21212 -0
- package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
- package/neural-interface/public/shared/ui-bookmarks.js +231 -0
- package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
- package/neural-interface/public/shared/ui-command-runner.js +534 -0
- package/neural-interface/public/shared/ui-cost-widget.js +602 -0
- package/neural-interface/public/shared/ui-detail.js +1454 -0
- package/neural-interface/public/shared/ui-explorer.js +1356 -0
- package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
- package/neural-interface/public/shared/ui-games.js +411 -0
- package/neural-interface/public/shared/ui-help.js +107 -0
- package/neural-interface/public/shared/ui-image-gallery.js +461 -0
- package/neural-interface/public/shared/ui-invite.js +519 -0
- package/neural-interface/public/shared/ui-keybinds.js +452 -0
- package/neural-interface/public/shared/ui-layouts.js +297 -0
- package/neural-interface/public/shared/ui-link.js +731 -0
- package/neural-interface/public/shared/ui-loading.js +255 -0
- package/neural-interface/public/shared/ui-menubar.js +499 -0
- package/neural-interface/public/shared/ui-multiselect.js +93 -0
- package/neural-interface/public/shared/ui-navbar.js +222 -0
- package/neural-interface/public/shared/ui-notifications.js +287 -0
- package/neural-interface/public/shared/ui-panels.js +309 -0
- package/neural-interface/public/shared/ui-resume.js +653 -0
- package/neural-interface/public/shared/ui-search.js +145 -0
- package/neural-interface/public/shared/ui-sessions.js +531 -0
- package/neural-interface/public/shared/ui-settings.js +5987 -0
- package/neural-interface/public/shared/ui-sidebar.js +1905 -0
- package/neural-interface/public/shared/ui-skills.js +2805 -0
- package/neural-interface/public/shared/ui-stats.js +38 -0
- package/neural-interface/public/shared/ui-sync.js +201 -0
- package/neural-interface/public/shared/ui-terminal.js +6385 -0
- package/neural-interface/public/shared/ui-tooltip.js +123 -0
- package/neural-interface/public/shared/ui-trash.js +439 -0
- package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
- package/neural-interface/public/shared/ui-tutorial.js +1117 -0
- package/neural-interface/public/shared/ui-update.js +244 -0
- package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
- package/neural-interface/public/shared/ui-workspaces.js +722 -0
- package/neural-interface/public/shared/utils.js +360 -0
- package/neural-interface/public/shared/vterm-buffer.js +1102 -0
- package/neural-interface/public/sw.js +52 -0
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/toolbar-album.svg +8 -0
- package/neural-interface/public/toolbar-question.svg +6 -0
- package/neural-interface/public/toolbar-sessions.svg +6 -0
- package/neural-interface/public/variant/2d/background.js +113 -0
- package/neural-interface/public/variant/2d/context-menu.js +133 -0
- package/neural-interface/public/variant/2d/gfx.js +45 -0
- package/neural-interface/public/variant/2d/graph.js +1635 -0
- package/neural-interface/public/variant/2d/hulls.js +207 -0
- package/neural-interface/public/variant/2d/lasso.js +135 -0
- package/neural-interface/public/variant/2d/layout.js +594 -0
- package/neural-interface/public/variant/2d/main.js +521 -0
- package/neural-interface/public/variant/2d/minimap.js +312 -0
- package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
- package/neural-interface/public/variant/3d/background.js +884 -0
- package/neural-interface/public/variant/3d/camera.js +661 -0
- package/neural-interface/public/variant/3d/gfx.js +116 -0
- package/neural-interface/public/variant/3d/graph.js +2399 -0
- package/neural-interface/public/variant/3d/main.js +500 -0
- package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
- package/neural-interface/scripts/rebuild-pty.js +100 -0
- package/neural-interface/server.js +15000 -0
- package/neural-interface/templates/CLAUDE-template.md +211 -0
- package/package.json +53 -0
- package/postinstall.js +135 -0
- package/setup.js +260 -0
- package/skills/leonardo/SKILL.md +159 -0
- package/skills/leonardo/icon.svg +1 -0
- package/skills/leonardo/modules/browser-guide.md +378 -0
- package/skills/leonardo/modules/image-prompter.md +277 -0
- package/skills/leonardo/modules/model-advisor.md +100 -0
- package/skills/leonardo/modules/prompt-library.md +110 -0
- package/skills/leonardo/modules/style-guide.md +186 -0
- package/skills/leonardo/modules/video-prompter.md +311 -0
- package/skills/synabun/SKILL.md +185 -0
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +374 -0
- package/skills/synabun/modules/changelog.md +194 -0
- package/skills/synabun/modules/idea.md +144 -0
- package/skills/synabun/modules/memorize.md +188 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')}</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 · <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
|
+
}
|