@objectifthunes/whiteboard 0.1.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/dist/index.js ADDED
@@ -0,0 +1,898 @@
1
+ // src/WhiteboardShell.tsx
2
+ import { useRef as useRef2, useCallback, useEffect as useEffect2 } from "react";
3
+
4
+ // src/ZoomBar.tsx
5
+ import { useEffect } from "react";
6
+
7
+ // src/icons.tsx
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ var defaults = (size = 14) => ({
10
+ xmlns: "http://www.w3.org/2000/svg",
11
+ width: size,
12
+ height: size,
13
+ viewBox: "0 0 24 24",
14
+ fill: "none",
15
+ stroke: "currentColor",
16
+ strokeWidth: 2,
17
+ strokeLinecap: "round",
18
+ strokeLinejoin: "round"
19
+ });
20
+ function Minus({ size, ...props }) {
21
+ return /* @__PURE__ */ jsx("svg", { ...defaults(size), ...props, children: /* @__PURE__ */ jsx("path", { d: "M5 12h14" }) });
22
+ }
23
+ function Plus({ size, ...props }) {
24
+ return /* @__PURE__ */ jsxs("svg", { ...defaults(size), ...props, children: [
25
+ /* @__PURE__ */ jsx("path", { d: "M5 12h14" }),
26
+ /* @__PURE__ */ jsx("path", { d: "M12 5v14" })
27
+ ] });
28
+ }
29
+ function ScanSearch({ size, ...props }) {
30
+ return /* @__PURE__ */ jsxs("svg", { ...defaults(size), ...props, children: [
31
+ /* @__PURE__ */ jsx("path", { d: "M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z", fill: "none", stroke: "none" }),
32
+ /* @__PURE__ */ jsx("path", { d: "M3 7V5a2 2 0 0 1 2-2h2" }),
33
+ /* @__PURE__ */ jsx("path", { d: "M17 3h2a2 2 0 0 1 2 2v2" }),
34
+ /* @__PURE__ */ jsx("path", { d: "M21 17v2a2 2 0 0 1-2 2h-2" }),
35
+ /* @__PURE__ */ jsx("path", { d: "M7 21H5a2 2 0 0 1-2-2v-2" }),
36
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3" }),
37
+ /* @__PURE__ */ jsx("path", { d: "m16 16-1.9-1.9" })
38
+ ] });
39
+ }
40
+ function RotateCcw({ size, ...props }) {
41
+ return /* @__PURE__ */ jsxs("svg", { ...defaults(size), ...props, children: [
42
+ /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
43
+ /* @__PURE__ */ jsx("path", { d: "M3 3v5h5" })
44
+ ] });
45
+ }
46
+ function Grid3x3({ size, ...props }) {
47
+ return /* @__PURE__ */ jsxs("svg", { ...defaults(size), ...props, children: [
48
+ /* @__PURE__ */ jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }),
49
+ /* @__PURE__ */ jsx("path", { d: "M3 9h18" }),
50
+ /* @__PURE__ */ jsx("path", { d: "M3 15h18" }),
51
+ /* @__PURE__ */ jsx("path", { d: "M9 3v18" }),
52
+ /* @__PURE__ */ jsx("path", { d: "M15 3v18" })
53
+ ] });
54
+ }
55
+ function Maximize2({ size, ...props }) {
56
+ return /* @__PURE__ */ jsxs("svg", { ...defaults(size), ...props, children: [
57
+ /* @__PURE__ */ jsx("polyline", { points: "15 3 21 3 21 9" }),
58
+ /* @__PURE__ */ jsx("polyline", { points: "9 21 3 21 3 15" }),
59
+ /* @__PURE__ */ jsx("line", { x1: "21", x2: "14", y1: "3", y2: "10" }),
60
+ /* @__PURE__ */ jsx("line", { x1: "3", x2: "10", y1: "21", y2: "14" })
61
+ ] });
62
+ }
63
+ function Loader2({ size, ...props }) {
64
+ return /* @__PURE__ */ jsx("svg", { ...defaults(size), ...props, children: /* @__PURE__ */ jsx("path", { d: "M21 12a9 9 0 1 1-6.219-8.56" }) });
65
+ }
66
+ function AlertTriangle({ size, ...props }) {
67
+ return /* @__PURE__ */ jsxs("svg", { ...defaults(size), ...props, children: [
68
+ /* @__PURE__ */ jsx("path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }),
69
+ /* @__PURE__ */ jsx("path", { d: "M12 9v4" }),
70
+ /* @__PURE__ */ jsx("path", { d: "M12 17h.01" })
71
+ ] });
72
+ }
73
+ function Check({ size, ...props }) {
74
+ return /* @__PURE__ */ jsx("svg", { ...defaults(size), ...props, children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" }) });
75
+ }
76
+ function X({ size, ...props }) {
77
+ return /* @__PURE__ */ jsxs("svg", { ...defaults(size), ...props, children: [
78
+ /* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }),
79
+ /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })
80
+ ] });
81
+ }
82
+
83
+ // src/store.ts
84
+ import { create } from "zustand";
85
+
86
+ // src/grid.ts
87
+ var WHITEBOARD_GRID = 20;
88
+ function snapToWhiteboardGrid(value) {
89
+ return Math.round(value / WHITEBOARD_GRID) * WHITEBOARD_GRID;
90
+ }
91
+
92
+ // src/store.ts
93
+ function computeWhiteboardFit(panels, viewportSize, padding = 60) {
94
+ if (panels.size === 0) return null;
95
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
96
+ for (const r of panels.values()) {
97
+ minX = Math.min(minX, r.x);
98
+ minY = Math.min(minY, r.y);
99
+ maxX = Math.max(maxX, r.x + r.width);
100
+ maxY = Math.max(maxY, r.y + r.height);
101
+ }
102
+ const contentW = maxX - minX + padding * 2;
103
+ const contentH = maxY - minY + padding * 2;
104
+ const vpW = viewportSize.width || window.innerWidth;
105
+ const vpH = viewportSize.height || window.innerHeight;
106
+ const fitScale = Math.min(vpW / contentW, vpH / contentH, 1.5);
107
+ const centerX = (minX + maxX) / 2;
108
+ const centerY = (minY + maxY) / 2;
109
+ return {
110
+ scale: Math.min(3, Math.max(0.2, fitScale)),
111
+ offset: {
112
+ x: vpW / 2 - centerX * fitScale,
113
+ y: vpH / 2 - centerY * fitScale
114
+ }
115
+ };
116
+ }
117
+ function computeWhiteboardRectFocus(rect, viewportSize, padding = 40, maxScale = 1.5) {
118
+ const vpW = viewportSize.width || window.innerWidth;
119
+ const vpH = viewportSize.height || window.innerHeight;
120
+ const safeWidth = Math.max(1, rect.width);
121
+ const safeHeight = Math.max(1, rect.height);
122
+ const fitScale = Math.min(
123
+ (vpW - padding * 2) / safeWidth,
124
+ (vpH - padding * 2) / safeHeight,
125
+ maxScale
126
+ );
127
+ const nextScale = Math.min(3, Math.max(0.2, fitScale));
128
+ return {
129
+ scale: nextScale,
130
+ offset: {
131
+ x: vpW / 2 - (rect.x + safeWidth / 2) * nextScale,
132
+ y: vpH / 2 - (rect.y + safeHeight / 2) * nextScale
133
+ }
134
+ };
135
+ }
136
+ var useWhiteboardStore = create((set, get) => ({
137
+ offset: { x: 0, y: 0 },
138
+ scale: 1,
139
+ viewportSize: { width: 0, height: 0 },
140
+ snapToGrid: false,
141
+ snapGridSize: WHITEBOARD_GRID,
142
+ panels: /* @__PURE__ */ new Map(),
143
+ resetFns: /* @__PURE__ */ new Map(),
144
+ registryVersion: 0,
145
+ setOffset: (v) => set((s) => ({ offset: typeof v === "function" ? v(s.offset) : v })),
146
+ setScale: (v) => set((s) => ({ scale: typeof v === "function" ? v(s.scale) : v })),
147
+ setViewportSize: (v) => set({ viewportSize: v }),
148
+ setSnapToGrid: (v) => set((s) => ({ snapToGrid: typeof v === "function" ? v(s.snapToGrid) : v })),
149
+ register: (id, rect) => {
150
+ get().panels.set(id, rect);
151
+ set((s) => ({ registryVersion: s.registryVersion + 1 }));
152
+ },
153
+ unregister: (id) => {
154
+ get().panels.delete(id);
155
+ set((s) => ({ registryVersion: s.registryVersion + 1 }));
156
+ },
157
+ registerReset: (id, fn) => {
158
+ get().resetFns.set(id, fn);
159
+ },
160
+ unregisterReset: (id) => {
161
+ get().resetFns.delete(id);
162
+ },
163
+ fitToContent: () => {
164
+ const { panels, viewportSize } = get();
165
+ const fit = computeWhiteboardFit(panels, viewportSize);
166
+ if (fit) set({ scale: fit.scale, offset: fit.offset });
167
+ },
168
+ focusPanel: (rect, options) => {
169
+ const { viewportSize } = get();
170
+ const fit = computeWhiteboardRectFocus(
171
+ rect,
172
+ viewportSize,
173
+ options?.padding ?? 40,
174
+ options?.maxScale ?? 1.5
175
+ );
176
+ set({ scale: fit.scale, offset: fit.offset });
177
+ },
178
+ resetWidgets: () => {
179
+ for (const fn of get().resetFns.values()) fn();
180
+ const attempt = (tries = 0) => {
181
+ const { panels, viewportSize } = get();
182
+ const fit = computeWhiteboardFit(panels, viewportSize);
183
+ if (fit) {
184
+ set({ scale: fit.scale, offset: fit.offset });
185
+ return;
186
+ }
187
+ if (tries < 6) requestAnimationFrame(() => attempt(tries + 1));
188
+ };
189
+ requestAnimationFrame(() => requestAnimationFrame(() => attempt()));
190
+ },
191
+ resetSession: () => set({
192
+ offset: { x: 0, y: 0 },
193
+ scale: 1,
194
+ viewportSize: { width: 0, height: 0 },
195
+ snapToGrid: false,
196
+ registryVersion: 0,
197
+ panels: /* @__PURE__ */ new Map(),
198
+ resetFns: /* @__PURE__ */ new Map()
199
+ })
200
+ }));
201
+
202
+ // src/ZoomBar.tsx
203
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
204
+ function ZoomBar({ extraActions }) {
205
+ const scale = useWhiteboardStore((s) => s.scale);
206
+ const viewportSize = useWhiteboardStore((s) => s.viewportSize);
207
+ const snapToGrid = useWhiteboardStore((s) => s.snapToGrid);
208
+ const setScale = useWhiteboardStore((s) => s.setScale);
209
+ const setOffset = useWhiteboardStore((s) => s.setOffset);
210
+ const setSnapToGrid = useWhiteboardStore((s) => s.setSnapToGrid);
211
+ const fitToContent = useWhiteboardStore((s) => s.fitToContent);
212
+ const resetWidgets = useWhiteboardStore((s) => s.resetWidgets);
213
+ useEffect(() => {
214
+ if (!snapToGrid) return;
215
+ window.dispatchEvent(new Event("whiteboard-snap-now"));
216
+ }, [snapToGrid]);
217
+ const zoomTo = (nextScale) => {
218
+ const clamped = Math.min(3, Math.max(0.2, nextScale));
219
+ const ratio = clamped / scale;
220
+ const cx = (viewportSize.width || window.innerWidth) / 2;
221
+ const cy = (viewportSize.height || window.innerHeight) / 2;
222
+ setOffset((prev) => ({
223
+ x: cx - (cx - prev.x) * ratio,
224
+ y: cy - (cy - prev.y) * ratio
225
+ }));
226
+ setScale(clamped);
227
+ };
228
+ return /* @__PURE__ */ jsxs2(
229
+ "div",
230
+ {
231
+ className: "zoom-bar",
232
+ onPointerDown: (e) => e.stopPropagation(),
233
+ onWheel: (e) => e.stopPropagation(),
234
+ children: [
235
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "wb-btn wb-btn--secondary wb-btn--icon-only zoom-bar__icon", onClick: () => zoomTo(scale * 0.8), title: "Zoom out", "aria-label": "Zoom out", children: /* @__PURE__ */ jsx2(Minus, { size: 14 }) }),
236
+ /* @__PURE__ */ jsxs2("span", { className: "zoom-bar__value", children: [
237
+ Math.round(scale * 100),
238
+ "%"
239
+ ] }),
240
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "wb-btn wb-btn--secondary wb-btn--icon-only zoom-bar__icon", onClick: () => zoomTo(scale * 1.2), title: "Zoom in", "aria-label": "Zoom in", children: /* @__PURE__ */ jsx2(Plus, { size: 14 }) }),
241
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "wb-btn wb-btn--secondary wb-btn--icon-only zoom-bar__action", onClick: fitToContent, title: "Fit camera to all panels", "aria-label": "Fit camera to all panels", children: /* @__PURE__ */ jsx2(ScanSearch, { size: 14 }) }),
242
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "wb-btn wb-btn--secondary wb-btn--icon-only zoom-bar__action", onClick: resetWidgets, title: "Reset panel positions", "aria-label": "Reset panel positions", children: /* @__PURE__ */ jsx2(RotateCcw, { size: 14 }) }),
243
+ /* @__PURE__ */ jsx2(
244
+ "button",
245
+ {
246
+ type: "button",
247
+ className: `wb-btn wb-btn--secondary wb-btn--icon-only zoom-bar__action${snapToGrid ? " is-active" : ""}`,
248
+ onClick: () => setSnapToGrid((prev) => !prev),
249
+ title: snapToGrid ? "Disable snap to grid" : "Enable snap to grid",
250
+ "aria-label": snapToGrid ? "Disable snap to grid" : "Enable snap to grid",
251
+ children: /* @__PURE__ */ jsx2(Grid3x3, { size: 14 })
252
+ }
253
+ ),
254
+ extraActions
255
+ ]
256
+ }
257
+ );
258
+ }
259
+
260
+ // src/Minimap.tsx
261
+ import { useRef } from "react";
262
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
263
+ var MAP_W = 200;
264
+ var MAP_H = 150;
265
+ var PADDING = 40;
266
+ var MIN_WORLD_SIZE = 1;
267
+ function Minimap({ loading = false }) {
268
+ useWhiteboardStore((s) => s.registryVersion);
269
+ const offset = useWhiteboardStore((s) => s.offset);
270
+ const scale = useWhiteboardStore((s) => s.scale);
271
+ const viewportSize = useWhiteboardStore((s) => s.viewportSize);
272
+ const panels = useWhiteboardStore((s) => s.panels);
273
+ const setOffset = useWhiteboardStore((s) => s.setOffset);
274
+ const setScale = useWhiteboardStore((s) => s.setScale);
275
+ const focusPanel = useWhiteboardStore((s) => s.focusPanel);
276
+ const containerRef = useRef(null);
277
+ const dragging = useRef(false);
278
+ const lastPanelTapRef = useRef(null);
279
+ const rectEntries = Array.from(panels.entries());
280
+ const visibleRectEntries = rectEntries.filter(([, rect]) => Number.isFinite(rect.width) && Number.isFinite(rect.height));
281
+ const rects = visibleRectEntries.map(([, rect]) => rect);
282
+ if (loading || rects.length === 0) {
283
+ return /* @__PURE__ */ jsx3("div", { className: "minimap minimap--loading", style: { width: MAP_W, height: MAP_H }, children: /* @__PURE__ */ jsx3("div", { className: "minimap__loading", children: /* @__PURE__ */ jsx3(Loader2, { size: 14, className: "icon-spin" }) }) });
284
+ }
285
+ const viewportWidth = viewportSize.width || window.innerWidth;
286
+ const viewportHeight = viewportSize.height || window.innerHeight;
287
+ const vpW = viewportWidth / scale;
288
+ const vpH = viewportHeight / scale;
289
+ const vpX = -offset.x / scale;
290
+ const vpY = -offset.y / scale;
291
+ let minX = Infinity;
292
+ let minY = Infinity;
293
+ let maxX = -Infinity;
294
+ let maxY = -Infinity;
295
+ for (const r of rects) {
296
+ minX = Math.min(minX, r.x);
297
+ minY = Math.min(minY, r.y);
298
+ maxX = Math.max(maxX, r.x + r.width);
299
+ maxY = Math.max(maxY, r.y + r.height);
300
+ }
301
+ minX -= PADDING;
302
+ minY -= PADDING;
303
+ maxX += PADDING;
304
+ maxY += PADDING;
305
+ const worldW = Math.max(MIN_WORLD_SIZE, maxX - minX);
306
+ const worldH = Math.max(MIN_WORLD_SIZE, maxY - minY);
307
+ const mapScale = Math.min(MAP_W / worldW, MAP_H / worldH);
308
+ const contentW = worldW * mapScale;
309
+ const contentH = worldH * mapScale;
310
+ const mapOffsetX = (MAP_W - contentW) / 2;
311
+ const mapOffsetY = (MAP_H - contentH) / 2;
312
+ const toMapX = (wx) => mapOffsetX + (wx - minX) * mapScale;
313
+ const toMapY = (wy) => mapOffsetY + (wy - minY) * mapScale;
314
+ const clientToWorld = (clientX, clientY) => {
315
+ if (!containerRef.current) return;
316
+ const mapRect = containerRef.current.getBoundingClientRect();
317
+ const localX = Math.min(MAP_W, Math.max(0, clientX - mapRect.left));
318
+ const localY = Math.min(MAP_H, Math.max(0, clientY - mapRect.top));
319
+ const boundedX = Math.min(contentW + mapOffsetX, Math.max(mapOffsetX, localX));
320
+ const boundedY = Math.min(contentH + mapOffsetY, Math.max(mapOffsetY, localY));
321
+ const worldX = (boundedX - mapOffsetX) / mapScale + minX;
322
+ const worldY = (boundedY - mapOffsetY) / mapScale + minY;
323
+ return { worldX, worldY };
324
+ };
325
+ const centerToWorld = (worldX, worldY, targetScale) => {
326
+ const clampedScale = Math.min(3, Math.max(0.2, targetScale));
327
+ setScale(clampedScale);
328
+ setOffset({
329
+ x: viewportWidth / 2 - worldX * clampedScale,
330
+ y: viewportHeight / 2 - worldY * clampedScale
331
+ });
332
+ };
333
+ const panTo = (clientX, clientY) => {
334
+ const world = clientToWorld(clientX, clientY);
335
+ if (!world) return;
336
+ centerToWorld(world.worldX, world.worldY, scale);
337
+ };
338
+ const onDown = (e) => {
339
+ e.stopPropagation();
340
+ e.preventDefault();
341
+ dragging.current = true;
342
+ e.currentTarget.setPointerCapture(e.pointerId);
343
+ panTo(e.clientX, e.clientY);
344
+ };
345
+ const onMove = (e) => {
346
+ if (!dragging.current) return;
347
+ panTo(e.clientX, e.clientY);
348
+ };
349
+ const onUp = () => {
350
+ dragging.current = false;
351
+ };
352
+ const onWheel = (e) => {
353
+ e.preventDefault();
354
+ e.stopPropagation();
355
+ const world = clientToWorld(e.clientX, e.clientY);
356
+ if (!world) return;
357
+ const factor = e.deltaY > 0 ? 0.9 : 1.1;
358
+ centerToWorld(world.worldX, world.worldY, scale * factor);
359
+ };
360
+ return /* @__PURE__ */ jsxs3(
361
+ "div",
362
+ {
363
+ ref: containerRef,
364
+ onPointerDown: onDown,
365
+ onPointerMove: onMove,
366
+ onPointerUp: onUp,
367
+ onPointerCancel: onUp,
368
+ onWheel,
369
+ className: "minimap",
370
+ style: {
371
+ width: MAP_W,
372
+ height: MAP_H
373
+ },
374
+ children: [
375
+ visibleRectEntries.map(([id, r]) => /* @__PURE__ */ jsx3(
376
+ "div",
377
+ {
378
+ className: "minimap__panel",
379
+ onPointerDown: (event) => {
380
+ event.stopPropagation();
381
+ },
382
+ onPointerUp: (event) => {
383
+ event.stopPropagation();
384
+ const now = Date.now();
385
+ const last = lastPanelTapRef.current;
386
+ if (last && last.id === id && now - last.time < 300) {
387
+ event.preventDefault();
388
+ focusPanel(r, { padding: r.focusPadding, maxScale: r.focusMaxScale });
389
+ lastPanelTapRef.current = null;
390
+ return;
391
+ }
392
+ lastPanelTapRef.current = { id, time: now };
393
+ },
394
+ onDoubleClick: (event) => {
395
+ event.preventDefault();
396
+ event.stopPropagation();
397
+ focusPanel(r, { padding: r.focusPadding, maxScale: r.focusMaxScale });
398
+ },
399
+ style: {
400
+ left: toMapX(r.x),
401
+ top: toMapY(r.y),
402
+ width: Math.max(1, r.width * mapScale),
403
+ height: Math.max(1, r.height * mapScale)
404
+ }
405
+ },
406
+ id
407
+ )),
408
+ /* @__PURE__ */ jsx3(
409
+ "div",
410
+ {
411
+ className: "minimap__viewport",
412
+ style: {
413
+ left: toMapX(vpX),
414
+ top: toMapY(vpY),
415
+ width: vpW * mapScale,
416
+ height: vpH * mapScale
417
+ }
418
+ }
419
+ )
420
+ ]
421
+ }
422
+ );
423
+ }
424
+
425
+ // src/WhiteboardShell.tsx
426
+ import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
427
+ function WhiteboardShell({ children, showMinimap = true, minimapLoading = false, extraActions }) {
428
+ const offset = useWhiteboardStore((s) => s.offset);
429
+ const scale = useWhiteboardStore((s) => s.scale);
430
+ const registryVersion = useWhiteboardStore((s) => s.registryVersion);
431
+ const viewportSize = useWhiteboardStore((s) => s.viewportSize);
432
+ const setOffset = useWhiteboardStore((s) => s.setOffset);
433
+ const setScale = useWhiteboardStore((s) => s.setScale);
434
+ const setViewportSize = useWhiteboardStore((s) => s.setViewportSize);
435
+ const shellRef = useRef2(null);
436
+ const panning = useRef2(false);
437
+ const last = useRef2({ x: 0, y: 0 });
438
+ const activePointerId = useRef2(null);
439
+ const scaleRef = useRef2(scale);
440
+ const hasFitted = useRef2(false);
441
+ useEffect2(() => {
442
+ return () => {
443
+ useWhiteboardStore.getState().resetSession();
444
+ };
445
+ }, []);
446
+ useEffect2(() => {
447
+ scaleRef.current = scale;
448
+ }, [scale]);
449
+ useEffect2(() => {
450
+ const shell = shellRef.current;
451
+ if (!shell) return;
452
+ const updateViewport = () => {
453
+ const rect = shell.getBoundingClientRect();
454
+ setViewportSize({
455
+ width: Math.max(0, rect.width),
456
+ height: Math.max(0, rect.height)
457
+ });
458
+ };
459
+ updateViewport();
460
+ if (typeof ResizeObserver === "undefined") return;
461
+ const observer = new ResizeObserver(updateViewport);
462
+ observer.observe(shell);
463
+ return () => observer.disconnect();
464
+ }, [setViewportSize]);
465
+ useEffect2(() => {
466
+ if (hasFitted.current) return;
467
+ const { panels } = useWhiteboardStore.getState();
468
+ if (panels.size === 0 || viewportSize.width <= 0 || viewportSize.height <= 0) return;
469
+ hasFitted.current = true;
470
+ requestAnimationFrame(() => {
471
+ useWhiteboardStore.getState().fitToContent();
472
+ });
473
+ }, [registryVersion, viewportSize]);
474
+ const onDown = useCallback((e) => {
475
+ if (e.target !== e.currentTarget) return;
476
+ if (e.button === 0 || e.button === 1) {
477
+ panning.current = true;
478
+ activePointerId.current = e.pointerId;
479
+ last.current = { x: e.clientX, y: e.clientY };
480
+ e.currentTarget.setPointerCapture(e.pointerId);
481
+ e.preventDefault();
482
+ }
483
+ }, []);
484
+ const onMove = useCallback((e) => {
485
+ if (!panning.current || activePointerId.current !== e.pointerId) return;
486
+ const dx = e.movementX ?? e.clientX - last.current.x;
487
+ const dy = e.movementY ?? e.clientY - last.current.y;
488
+ setOffset((p) => ({ x: p.x + dx, y: p.y + dy }));
489
+ last.current = { x: e.clientX, y: e.clientY };
490
+ }, [setOffset]);
491
+ const stopPan = useCallback((e) => {
492
+ if (activePointerId.current !== null) {
493
+ try {
494
+ e.currentTarget.releasePointerCapture(activePointerId.current);
495
+ } catch {
496
+ }
497
+ }
498
+ panning.current = false;
499
+ activePointerId.current = null;
500
+ }, []);
501
+ const onWheel = useCallback((e) => {
502
+ const s = scaleRef.current;
503
+ const rect = e.currentTarget.getBoundingClientRect();
504
+ const anchor = { x: e.clientX - rect.left, y: e.clientY - rect.top };
505
+ const nextScale = Math.min(3, Math.max(0.2, s * (e.deltaY > 0 ? 0.9 : 1.1)));
506
+ const ratio = nextScale / s;
507
+ setOffset((prev) => ({
508
+ x: anchor.x - (anchor.x - prev.x) * ratio,
509
+ y: anchor.y - (anchor.y - prev.y) * ratio
510
+ }));
511
+ setScale(nextScale);
512
+ }, [setOffset, setScale]);
513
+ return /* @__PURE__ */ jsxs4(Fragment, { children: [
514
+ /* @__PURE__ */ jsx4(
515
+ "div",
516
+ {
517
+ ref: shellRef,
518
+ onPointerDown: onDown,
519
+ onPointerMove: onMove,
520
+ onPointerUp: stopPan,
521
+ onPointerCancel: stopPan,
522
+ onWheel,
523
+ onContextMenu: (e) => e.preventDefault(),
524
+ className: "whiteboard-shell",
525
+ children: /* @__PURE__ */ jsxs4(
526
+ "div",
527
+ {
528
+ className: "whiteboard-canvas",
529
+ style: { transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` },
530
+ children: [
531
+ /* @__PURE__ */ jsx4("div", { className: "whiteboard-grid", "aria-hidden": true }),
532
+ children
533
+ ]
534
+ }
535
+ )
536
+ }
537
+ ),
538
+ /* @__PURE__ */ jsx4(ZoomBar, { extraActions }),
539
+ showMinimap ? /* @__PURE__ */ jsx4(Minimap, { loading: minimapLoading }) : null
540
+ ] });
541
+ }
542
+
543
+ // src/FloatingPanel.tsx
544
+ import { useRef as useRef3, useId, useState, useCallback as useCallback2, useEffect as useEffect3, useLayoutEffect, memo } from "react";
545
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
546
+ var FloatingPanel = memo(function FloatingPanel2({
547
+ title,
548
+ defaultPosition,
549
+ width = 300,
550
+ className,
551
+ trackRect: trackRectRef,
552
+ focusable,
553
+ focusPadding = 40,
554
+ focusMaxScale = 1.5,
555
+ headerActions,
556
+ children
557
+ }) {
558
+ const panelId = useId();
559
+ const [pos, setPos] = useState(defaultPosition);
560
+ const dragging = useRef3(false);
561
+ const posRef = useRef3(pos);
562
+ const elRef = useRef3(null);
563
+ const lastRegisteredRectRef = useRef3(null);
564
+ const scale = useWhiteboardStore((s) => s.scale);
565
+ const snapToGrid = useWhiteboardStore((s) => s.snapToGrid);
566
+ const snapGridSize = useWhiteboardStore((s) => s.snapGridSize);
567
+ const register = useWhiteboardStore((s) => s.register);
568
+ const unregister = useWhiteboardStore((s) => s.unregister);
569
+ const registerReset = useWhiteboardStore((s) => s.registerReset);
570
+ const unregisterReset = useWhiteboardStore((s) => s.unregisterReset);
571
+ const focusPanel = useWhiteboardStore((s) => s.focusPanel);
572
+ const scaleRef = useRef3(scale);
573
+ const snapToGridRef = useRef3(false);
574
+ const snapGridSizeRef = useRef3(20);
575
+ const defaultPosRef = useRef3(defaultPosition);
576
+ const cleanupRef = useRef3(null);
577
+ const lastTapRef = useRef3(null);
578
+ useEffect3(() => {
579
+ scaleRef.current = scale;
580
+ }, [scale]);
581
+ useEffect3(() => {
582
+ snapToGridRef.current = snapToGrid;
583
+ snapGridSizeRef.current = snapGridSize;
584
+ }, [snapToGrid, snapGridSize]);
585
+ useEffect3(() => {
586
+ const onSnapNow = () => {
587
+ const snapSize = snapGridSizeRef.current;
588
+ setPos((current) => {
589
+ const next = {
590
+ x: Math.round(current.x / snapSize) * snapSize,
591
+ y: Math.round(current.y / snapSize) * snapSize
592
+ };
593
+ return next.x === current.x && next.y === current.y ? current : next;
594
+ });
595
+ };
596
+ window.addEventListener("whiteboard-snap-now", onSnapNow);
597
+ return () => window.removeEventListener("whiteboard-snap-now", onSnapNow);
598
+ }, []);
599
+ useEffect3(() => {
600
+ posRef.current = pos;
601
+ }, [pos]);
602
+ const registerRect = useCallback2(() => {
603
+ const el = elRef.current;
604
+ if (!el) return;
605
+ const nextRect = {
606
+ x: posRef.current.x,
607
+ y: posRef.current.y,
608
+ width: el.offsetWidth,
609
+ height: el.offsetHeight,
610
+ focusPadding,
611
+ focusMaxScale
612
+ };
613
+ const prev = lastRegisteredRectRef.current;
614
+ if (prev && prev.x === nextRect.x && prev.y === nextRect.y && prev.width === nextRect.width && prev.height === nextRect.height && prev.focusPadding === nextRect.focusPadding && prev.focusMaxScale === nextRect.focusMaxScale) {
615
+ return;
616
+ }
617
+ lastRegisteredRectRef.current = nextRect;
618
+ register(panelId, nextRect);
619
+ if (trackRectRef) {
620
+ trackRectRef.current = nextRect;
621
+ }
622
+ }, [focusMaxScale, focusPadding, panelId, register, trackRectRef]);
623
+ const getCurrentRect = useCallback2(() => {
624
+ const el = elRef.current;
625
+ if (!el) return null;
626
+ return {
627
+ x: posRef.current.x,
628
+ y: posRef.current.y,
629
+ width: el.offsetWidth,
630
+ height: el.offsetHeight,
631
+ focusPadding,
632
+ focusMaxScale
633
+ };
634
+ }, [focusMaxScale, focusPadding]);
635
+ const handleFocus = useCallback2(() => {
636
+ const rect = getCurrentRect();
637
+ if (!rect) return;
638
+ focusPanel(rect, { padding: focusPadding, maxScale: focusMaxScale });
639
+ }, [focusPanel, focusPadding, focusMaxScale, getCurrentRect]);
640
+ useEffect3(() => {
641
+ registerReset(panelId, () => setPos(defaultPosRef.current));
642
+ return () => {
643
+ cleanupRef.current?.();
644
+ unregister(panelId);
645
+ unregisterReset(panelId);
646
+ lastRegisteredRectRef.current = null;
647
+ };
648
+ }, [panelId, registerReset, unregister, unregisterReset]);
649
+ useLayoutEffect(() => {
650
+ registerRect();
651
+ }, [pos.x, pos.y, width, registerRect]);
652
+ useEffect3(() => {
653
+ const el = elRef.current;
654
+ if (!el) return;
655
+ if (typeof ResizeObserver === "undefined") return;
656
+ const obs = new ResizeObserver(() => {
657
+ registerRect();
658
+ });
659
+ obs.observe(el);
660
+ return () => obs.disconnect();
661
+ }, [registerRect]);
662
+ const onDown = useCallback2((e) => {
663
+ if (e.button !== 0) return;
664
+ dragging.current = true;
665
+ const startX = e.clientX;
666
+ const startY = e.clientY;
667
+ const startPosX = posRef.current.x;
668
+ const startPosY = posRef.current.y;
669
+ const startScale = scaleRef.current;
670
+ e.preventDefault();
671
+ e.stopPropagation();
672
+ e.target.setPointerCapture(e.pointerId);
673
+ const move = (ev) => {
674
+ if (!dragging.current) return;
675
+ const rawX = startPosX + (ev.clientX - startX) / startScale;
676
+ const rawY = startPosY + (ev.clientY - startY) / startScale;
677
+ const snapSize = snapGridSizeRef.current;
678
+ const nextX = snapToGridRef.current ? Math.round(rawX / snapSize) * snapSize : rawX;
679
+ const nextY = snapToGridRef.current ? Math.round(rawY / snapSize) * snapSize : rawY;
680
+ setPos({
681
+ x: nextX,
682
+ y: nextY
683
+ });
684
+ };
685
+ const up = () => {
686
+ dragging.current = false;
687
+ window.removeEventListener("pointermove", move);
688
+ window.removeEventListener("pointerup", up);
689
+ cleanupRef.current = null;
690
+ };
691
+ cleanupRef.current?.();
692
+ window.addEventListener("pointermove", move);
693
+ window.addEventListener("pointerup", up);
694
+ cleanupRef.current = up;
695
+ }, []);
696
+ const panelClassName = className ? `floating-panel ${className}` : "floating-panel";
697
+ return /* @__PURE__ */ jsxs5(
698
+ "div",
699
+ {
700
+ ref: elRef,
701
+ className: panelClassName,
702
+ style: { left: pos.x, top: pos.y, width },
703
+ onPointerDown: (e) => e.stopPropagation(),
704
+ onPointerUp: (e) => {
705
+ if (dragging.current) return;
706
+ const now = Date.now();
707
+ const last = lastTapRef.current;
708
+ if (last && now - last.time < 300) {
709
+ const dx = e.clientX - last.x;
710
+ const dy = e.clientY - last.y;
711
+ if (dx * dx + dy * dy < 100) {
712
+ e.stopPropagation();
713
+ handleFocus();
714
+ lastTapRef.current = null;
715
+ return;
716
+ }
717
+ }
718
+ lastTapRef.current = { time: now, x: e.clientX, y: e.clientY };
719
+ },
720
+ onWheel: (e) => e.stopPropagation(),
721
+ onDoubleClick: (e) => {
722
+ e.stopPropagation();
723
+ handleFocus();
724
+ },
725
+ children: [
726
+ /* @__PURE__ */ jsxs5("div", { onPointerDown: onDown, className: "floating-panel__header", children: [
727
+ /* @__PURE__ */ jsx5("strong", { className: "floating-panel__title", children: title }),
728
+ headerActions,
729
+ focusable && /* @__PURE__ */ jsx5(
730
+ "button",
731
+ {
732
+ type: "button",
733
+ className: "wb-btn wb-btn--secondary wb-btn--icon-only floating-panel__focus",
734
+ onClick: handleFocus,
735
+ onPointerDown: (e) => e.stopPropagation(),
736
+ title: "Focus on this panel",
737
+ "aria-label": "Focus on this panel",
738
+ children: /* @__PURE__ */ jsx5(Maximize2, { size: 14 })
739
+ }
740
+ )
741
+ ] }),
742
+ /* @__PURE__ */ jsx5("div", { className: "floating-panel__body", children })
743
+ ]
744
+ }
745
+ );
746
+ });
747
+ function usePanelRect(initial) {
748
+ const ref = useRef3({ ...initial, width: 0, height: 0 });
749
+ return ref;
750
+ }
751
+ function belowPanel(rect, gap = WHITEBOARD_GRID) {
752
+ return { x: rect.x, y: rect.y + rect.height + gap };
753
+ }
754
+
755
+ // src/ConfirmDialog.tsx
756
+ import { useEffect as useEffect4 } from "react";
757
+ import { createPortal } from "react-dom";
758
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
759
+ function ConfirmDialog({
760
+ open,
761
+ title,
762
+ message,
763
+ onConfirm,
764
+ onCancel,
765
+ confirmLabel = "Confirm",
766
+ loading,
767
+ error
768
+ }) {
769
+ useEffect4(() => {
770
+ if (!open) return;
771
+ const onKey = (e) => {
772
+ if (e.key === "Escape") onCancel();
773
+ };
774
+ window.addEventListener("keydown", onKey);
775
+ return () => window.removeEventListener("keydown", onKey);
776
+ }, [open, onCancel]);
777
+ if (!open || typeof document === "undefined") return null;
778
+ return createPortal(
779
+ /* @__PURE__ */ jsx6("div", { className: "confirm-modal-overlay", onMouseDown: onCancel, children: /* @__PURE__ */ jsxs6(
780
+ "div",
781
+ {
782
+ className: "confirm-modal",
783
+ role: "dialog",
784
+ "aria-modal": "true",
785
+ "aria-label": title,
786
+ onMouseDown: (e) => e.stopPropagation(),
787
+ children: [
788
+ /* @__PURE__ */ jsxs6("div", { className: "confirm-modal__header", children: [
789
+ /* @__PURE__ */ jsxs6("span", { className: "confirm-modal__title", children: [
790
+ /* @__PURE__ */ jsx6(AlertTriangle, { size: 16 }),
791
+ title
792
+ ] }),
793
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "wb-btn wb-btn--secondary wb-btn--icon-only", onClick: onCancel, "aria-label": "Close dialog", children: /* @__PURE__ */ jsx6(X, { size: 14 }) })
794
+ ] }),
795
+ /* @__PURE__ */ jsx6("p", { className: "confirm-modal__message", children: message }),
796
+ error && /* @__PURE__ */ jsx6("div", { className: "wb-alert wb-alert--error", children: error }),
797
+ /* @__PURE__ */ jsxs6("div", { className: "wb-btn-row", children: [
798
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "wb-btn wb-btn--secondary", onClick: onCancel, children: "Cancel" }),
799
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "wb-btn wb-btn--danger", onClick: onConfirm, disabled: loading, children: loading ? "Deleting..." : /* @__PURE__ */ jsxs6(Fragment2, { children: [
800
+ /* @__PURE__ */ jsx6(Check, { size: 14 }),
801
+ confirmLabel
802
+ ] }) })
803
+ ] })
804
+ ]
805
+ }
806
+ ) }),
807
+ document.body
808
+ );
809
+ }
810
+
811
+ // src/PanelErrorBoundary.tsx
812
+ import { Component } from "react";
813
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
814
+ var PanelErrorBoundary = class extends Component {
815
+ constructor() {
816
+ super(...arguments);
817
+ this.state = { error: null };
818
+ }
819
+ static getDerivedStateFromError(error) {
820
+ return { error };
821
+ }
822
+ render() {
823
+ if (this.state.error) {
824
+ return /* @__PURE__ */ jsxs7("div", { className: "wb-stack wb-stack--sm", children: [
825
+ /* @__PURE__ */ jsx7("div", { className: "wb-alert wb-alert--error", children: this.props.fallbackMessage ?? "This panel crashed." }),
826
+ /* @__PURE__ */ jsx7(
827
+ "button",
828
+ {
829
+ type: "button",
830
+ className: "wb-btn wb-btn--secondary",
831
+ onClick: () => this.setState({ error: null }),
832
+ children: "Retry"
833
+ }
834
+ )
835
+ ] });
836
+ }
837
+ return this.props.children;
838
+ }
839
+ };
840
+
841
+ // src/useWhiteboardLayout.ts
842
+ import { useMemo } from "react";
843
+ function useWhiteboardLayout({
844
+ widths,
845
+ startX = 20,
846
+ y = 40,
847
+ gap = 20
848
+ }) {
849
+ const panelWidth = useMemo(() => {
850
+ const normalized = {};
851
+ for (const [key, value] of Object.entries(widths)) {
852
+ normalized[key] = snapToWhiteboardGrid(value);
853
+ }
854
+ return normalized;
855
+ }, [widths]);
856
+ const layout = useMemo(
857
+ () => ({
858
+ startX: snapToWhiteboardGrid(startX),
859
+ y: snapToWhiteboardGrid(y),
860
+ gap: snapToWhiteboardGrid(gap)
861
+ }),
862
+ [startX, y, gap]
863
+ );
864
+ const positions = useMemo(() => {
865
+ const next = {};
866
+ let x = layout.startX;
867
+ for (const [key, width] of Object.entries(panelWidth)) {
868
+ ;
869
+ next[key] = { x, y: layout.y };
870
+ x += width + layout.gap;
871
+ }
872
+ return next;
873
+ }, [layout.gap, layout.startX, layout.y, panelWidth]);
874
+ return { layout, panelWidth, positions };
875
+ }
876
+
877
+ // src/cn.ts
878
+ function cn(...args) {
879
+ return args.filter(Boolean).join(" ");
880
+ }
881
+ export {
882
+ ConfirmDialog,
883
+ FloatingPanel,
884
+ Minimap,
885
+ PanelErrorBoundary,
886
+ WHITEBOARD_GRID,
887
+ WhiteboardShell,
888
+ ZoomBar,
889
+ belowPanel,
890
+ cn,
891
+ computeWhiteboardFit,
892
+ computeWhiteboardRectFocus,
893
+ snapToWhiteboardGrid,
894
+ usePanelRect,
895
+ useWhiteboardLayout,
896
+ useWhiteboardStore
897
+ };
898
+ //# sourceMappingURL=index.js.map