pmx-canvas 0.1.16 → 0.1.17
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 +65 -0
- package/Readme.md +2 -2
- package/dist/canvas/global.css +25 -0
- package/dist/canvas/index.js +72 -72
- package/dist/types/client/canvas/AnnotationLayer.d.ts +4 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -1
- package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
- package/dist/types/client/icons.d.ts +4 -0
- package/dist/types/client/state/canvas-store.d.ts +16 -1
- package/dist/types/client/types.d.ts +20 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -0
- package/dist/types/server/canvas-serialization.d.ts +23 -1
- package/dist/types/server/canvas-state.d.ts +27 -1
- package/dist/types/server/index.d.ts +7 -2
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/dist/types/server/spatial-analysis.d.ts +11 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +17 -0
- package/src/cli/agent.ts +6 -0
- package/src/client/App.tsx +60 -3
- package/src/client/canvas/AnnotationLayer.tsx +28 -0
- package/src/client/canvas/CanvasViewport.tsx +169 -10
- package/src/client/canvas/ContextPinBar.tsx +2 -1
- package/src/client/canvas/use-pan-zoom.ts +10 -5
- package/src/client/icons.tsx +22 -0
- package/src/client/state/canvas-store.ts +52 -2
- package/src/client/state/sse-bridge.ts +35 -1
- package/src/client/theme/global.css +25 -0
- package/src/client/types.ts +17 -0
- package/src/mcp/canvas-access.ts +10 -0
- package/src/mcp/server.ts +35 -4
- package/src/server/canvas-schema.ts +25 -0
- package/src/server/canvas-serialization.ts +69 -1
- package/src/server/canvas-state.ts +74 -2
- package/src/server/diagram-presets.ts +54 -19
- package/src/server/index.ts +20 -3
- package/src/server/mutation-history.ts +2 -0
- package/src/server/server.ts +77 -2
- package/src/server/spatial-analysis.ts +46 -1
- package/src/shared/semantic-attention.ts +4 -2
|
@@ -20,16 +20,20 @@ import {
|
|
|
20
20
|
draggingEdge,
|
|
21
21
|
edges,
|
|
22
22
|
expandedNodeId,
|
|
23
|
+
annotations,
|
|
23
24
|
nodes,
|
|
24
25
|
selectNodes,
|
|
25
26
|
setViewport,
|
|
26
27
|
viewport,
|
|
28
|
+
createAnnotationFromClient,
|
|
29
|
+
removeAnnotationFromClient,
|
|
27
30
|
} from '../state/canvas-store';
|
|
28
31
|
import { createEdgeFromClient, createNodeFromClient } from '../state/intent-bridge';
|
|
29
|
-
import type { CanvasNodeState } from '../types';
|
|
32
|
+
import type { CanvasAnnotation, CanvasNodeState } from '../types';
|
|
30
33
|
import { FocusFieldLayer } from './FocusFieldLayer';
|
|
31
34
|
import { CanvasNode } from './CanvasNode';
|
|
32
35
|
import { EdgeLayer } from './EdgeLayer';
|
|
36
|
+
import { AnnotationLayer } from './AnnotationLayer';
|
|
33
37
|
import { activeGuides } from './snap-guides';
|
|
34
38
|
import { usePanZoom } from './use-pan-zoom';
|
|
35
39
|
|
|
@@ -70,6 +74,45 @@ function renderNodeContent(node: CanvasNodeState) {
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
function distanceToSegment(
|
|
78
|
+
point: { x: number; y: number },
|
|
79
|
+
start: { x: number; y: number },
|
|
80
|
+
end: { x: number; y: number },
|
|
81
|
+
): number {
|
|
82
|
+
const dx = end.x - start.x;
|
|
83
|
+
const dy = end.y - start.y;
|
|
84
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
85
|
+
if (lengthSquared === 0) return Math.hypot(point.x - start.x, point.y - start.y);
|
|
86
|
+
const t = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared));
|
|
87
|
+
return Math.hypot(point.x - (start.x + t * dx), point.y - (start.y + t * dy));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findAnnotationAtPoint(
|
|
91
|
+
annotationList: CanvasAnnotation[],
|
|
92
|
+
point: { x: number; y: number },
|
|
93
|
+
hitRadius: number,
|
|
94
|
+
): CanvasAnnotation | null {
|
|
95
|
+
for (let i = annotationList.length - 1; i >= 0; i--) {
|
|
96
|
+
const annotation = annotationList[i];
|
|
97
|
+
if (!annotation) continue;
|
|
98
|
+
const pad = hitRadius + annotation.width;
|
|
99
|
+
if (
|
|
100
|
+
point.x < annotation.bounds.x - pad ||
|
|
101
|
+
point.x > annotation.bounds.x + annotation.bounds.width + pad ||
|
|
102
|
+
point.y < annotation.bounds.y - pad ||
|
|
103
|
+
point.y > annotation.bounds.y + annotation.bounds.height + pad
|
|
104
|
+
) continue;
|
|
105
|
+
for (let index = 1; index < annotation.points.length; index++) {
|
|
106
|
+
const start = annotation.points[index - 1];
|
|
107
|
+
const end = annotation.points[index];
|
|
108
|
+
if (start && end && distanceToSegment(point, start, end) <= hitRadius + annotation.width / 2) {
|
|
109
|
+
return annotation;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
73
116
|
interface LassoRect {
|
|
74
117
|
startX: number;
|
|
75
118
|
startY: number;
|
|
@@ -77,9 +120,27 @@ interface LassoRect {
|
|
|
77
120
|
currentY: number;
|
|
78
121
|
}
|
|
79
122
|
|
|
123
|
+
interface AnnotationDraft {
|
|
124
|
+
id: string;
|
|
125
|
+
type: 'freehand';
|
|
126
|
+
points: Array<{ x: number; y: number }>;
|
|
127
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
128
|
+
color: string;
|
|
129
|
+
width: number;
|
|
130
|
+
createdAt: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ANNOTATION_COLOR = 'currentColor';
|
|
134
|
+
const ANNOTATION_WIDTH = 4;
|
|
135
|
+
const ERASER_HIT_RADIUS = 14;
|
|
136
|
+
|
|
137
|
+
type AnnotationTool = 'pen' | 'eraser' | null;
|
|
138
|
+
|
|
80
139
|
interface CanvasViewportProps {
|
|
81
140
|
onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
|
|
82
141
|
onCanvasContextMenu?: (e: MouseEvent, canvasX: number, canvasY: number) => void;
|
|
142
|
+
annotationMode?: boolean;
|
|
143
|
+
annotationTool?: AnnotationTool;
|
|
83
144
|
}
|
|
84
145
|
|
|
85
146
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
|
|
@@ -165,10 +226,13 @@ export function getRenderableWorldNodes(
|
|
|
165
226
|
return worldNodes;
|
|
166
227
|
}
|
|
167
228
|
|
|
168
|
-
export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: CanvasViewportProps) {
|
|
229
|
+
export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotationMode = false, annotationTool = null }: CanvasViewportProps) {
|
|
169
230
|
const v = viewport.value;
|
|
170
231
|
const isLassoing = useRef(false);
|
|
232
|
+
const isAnnotating = useRef(false);
|
|
233
|
+
const annotationPoints = useRef<Array<{ x: number; y: number }>>([]);
|
|
171
234
|
const [lasso, setLasso] = useState<LassoRect | null>(null);
|
|
235
|
+
const [draftAnnotation, setDraftAnnotation] = useState<AnnotationDraft | null>(null);
|
|
172
236
|
const [dropActive, setDropActive] = useState(false);
|
|
173
237
|
const dropCounter = useRef(0);
|
|
174
238
|
// Ref mirrors lasso state so pointer handlers always read the latest value
|
|
@@ -177,15 +241,16 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
177
241
|
|
|
178
242
|
const containerRef = usePanZoom({
|
|
179
243
|
viewport,
|
|
244
|
+
disabled: annotationMode,
|
|
180
245
|
onViewportChange: (next) => {
|
|
181
246
|
// Don't pan while lassoing — usePanZoom's pointerdown still fires
|
|
182
247
|
// (native listener) before our Preact handler can stopPropagation.
|
|
183
|
-
if (isLassoing.current) return;
|
|
248
|
+
if (isLassoing.current || annotationMode) return;
|
|
184
249
|
cancelViewportAnimation();
|
|
185
250
|
setViewport(next);
|
|
186
251
|
},
|
|
187
252
|
onViewportCommit: (next) => {
|
|
188
|
-
if (isLassoing.current) return;
|
|
253
|
+
if (isLassoing.current || annotationMode) return;
|
|
189
254
|
cancelViewportAnimation();
|
|
190
255
|
commitViewport(next);
|
|
191
256
|
},
|
|
@@ -222,7 +287,53 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
222
287
|
const handlePointerDown = useCallback(
|
|
223
288
|
(e: PointerEvent) => {
|
|
224
289
|
const container = containerRef.current;
|
|
225
|
-
if (!container
|
|
290
|
+
if (!container) return;
|
|
291
|
+
|
|
292
|
+
if (annotationTool === 'eraser') {
|
|
293
|
+
const target = e.target instanceof Element ? e.target : null;
|
|
294
|
+
if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
e.stopPropagation();
|
|
297
|
+
const rect = container.getBoundingClientRect();
|
|
298
|
+
const vp = viewport.value;
|
|
299
|
+
const point = {
|
|
300
|
+
x: (e.clientX - rect.left - vp.x) / vp.scale,
|
|
301
|
+
y: (e.clientY - rect.top - vp.y) / vp.scale,
|
|
302
|
+
};
|
|
303
|
+
const hit = findAnnotationAtPoint(Array.from(annotations.value.values()), point, ERASER_HIT_RADIUS / vp.scale);
|
|
304
|
+
if (hit) void removeAnnotationFromClient(hit.id);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (annotationTool === 'pen') {
|
|
309
|
+
const target = e.target instanceof Element ? e.target : null;
|
|
310
|
+
if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
e.stopPropagation();
|
|
313
|
+
activeNodeId.value = null;
|
|
314
|
+
clearSelection();
|
|
315
|
+
isAnnotating.current = true;
|
|
316
|
+
const rect = container.getBoundingClientRect();
|
|
317
|
+
const vp = viewport.value;
|
|
318
|
+
const point = {
|
|
319
|
+
x: (e.clientX - rect.left - vp.x) / vp.scale,
|
|
320
|
+
y: (e.clientY - rect.top - vp.y) / vp.scale,
|
|
321
|
+
};
|
|
322
|
+
annotationPoints.current = [point];
|
|
323
|
+
setDraftAnnotation({
|
|
324
|
+
id: 'draft-annotation',
|
|
325
|
+
type: 'freehand',
|
|
326
|
+
points: [point],
|
|
327
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
328
|
+
color: ANNOTATION_COLOR,
|
|
329
|
+
width: ANNOTATION_WIDTH,
|
|
330
|
+
createdAt: '',
|
|
331
|
+
});
|
|
332
|
+
container.setPointerCapture(e.pointerId);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (e.target !== container) return;
|
|
226
337
|
|
|
227
338
|
if (!e.shiftKey) {
|
|
228
339
|
if (!lassoRef.current) {
|
|
@@ -243,11 +354,27 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
243
354
|
setLasso(initial);
|
|
244
355
|
container.setPointerCapture(e.pointerId);
|
|
245
356
|
},
|
|
246
|
-
[containerRef],
|
|
357
|
+
[annotationTool, containerRef],
|
|
247
358
|
);
|
|
248
359
|
|
|
249
360
|
const handlePointerMove = useCallback(
|
|
250
361
|
(e: PointerEvent) => {
|
|
362
|
+
if (isAnnotating.current) {
|
|
363
|
+
const container = containerRef.current;
|
|
364
|
+
if (!container) return;
|
|
365
|
+
const rect = container.getBoundingClientRect();
|
|
366
|
+
const vp = viewport.value;
|
|
367
|
+
const point = {
|
|
368
|
+
x: (e.clientX - rect.left - vp.x) / vp.scale,
|
|
369
|
+
y: (e.clientY - rect.top - vp.y) / vp.scale,
|
|
370
|
+
};
|
|
371
|
+
const previous = annotationPoints.current.at(-1);
|
|
372
|
+
if (previous && Math.hypot(point.x - previous.x, point.y - previous.y) < 2) return;
|
|
373
|
+
annotationPoints.current = [...annotationPoints.current, point];
|
|
374
|
+
setDraftAnnotation((draft) => draft ? { ...draft, points: annotationPoints.current } : null);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
251
378
|
if (!isLassoing.current || !lassoRef.current) return;
|
|
252
379
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
253
380
|
if (!rect) return;
|
|
@@ -262,7 +389,22 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
262
389
|
[containerRef],
|
|
263
390
|
);
|
|
264
391
|
|
|
265
|
-
const handlePointerUp = useCallback(() => {
|
|
392
|
+
const handlePointerUp = useCallback((e: PointerEvent) => {
|
|
393
|
+
if (isAnnotating.current) {
|
|
394
|
+
isAnnotating.current = false;
|
|
395
|
+
const points = annotationPoints.current;
|
|
396
|
+
annotationPoints.current = [];
|
|
397
|
+
setDraftAnnotation(null);
|
|
398
|
+
if (points.length >= 2) {
|
|
399
|
+
void createAnnotationFromClient({ points, color: ANNOTATION_COLOR, width: ANNOTATION_WIDTH });
|
|
400
|
+
}
|
|
401
|
+
const container = containerRef.current;
|
|
402
|
+
if (container?.hasPointerCapture(e.pointerId)) {
|
|
403
|
+
container.releasePointerCapture(e.pointerId);
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
266
408
|
const current = lassoRef.current;
|
|
267
409
|
if (!isLassoing.current || !current) return;
|
|
268
410
|
isLassoing.current = false;
|
|
@@ -306,6 +448,14 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
306
448
|
setLasso(null);
|
|
307
449
|
}, []);
|
|
308
450
|
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
if (annotationMode) return;
|
|
453
|
+
if (!isAnnotating.current && !draftAnnotation) return;
|
|
454
|
+
isAnnotating.current = false;
|
|
455
|
+
annotationPoints.current = [];
|
|
456
|
+
setDraftAnnotation(null);
|
|
457
|
+
}, [annotationMode, draftAnnotation]);
|
|
458
|
+
|
|
309
459
|
// ── Drag-to-connect: track cursor in world space, hit-test on drop ──
|
|
310
460
|
useEffect(() => {
|
|
311
461
|
function handleMove(e: PointerEvent) {
|
|
@@ -359,6 +509,7 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
359
509
|
// ── Double-click on background → create new markdown node ──
|
|
360
510
|
const handleDblClick = useCallback(
|
|
361
511
|
(e: MouseEvent) => {
|
|
512
|
+
if (annotationMode) return;
|
|
362
513
|
const container = containerRef.current;
|
|
363
514
|
if (!container || e.target !== container) return;
|
|
364
515
|
const rect = container.getBoundingClientRect();
|
|
@@ -377,11 +528,12 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
377
528
|
height: nodeH,
|
|
378
529
|
});
|
|
379
530
|
},
|
|
380
|
-
[containerRef],
|
|
531
|
+
[annotationMode, containerRef],
|
|
381
532
|
);
|
|
382
533
|
|
|
383
534
|
const handleContextMenu = useCallback(
|
|
384
535
|
(e: MouseEvent) => {
|
|
536
|
+
if (annotationMode) return;
|
|
385
537
|
if (!onCanvasContextMenu) return;
|
|
386
538
|
|
|
387
539
|
const container = containerRef.current;
|
|
@@ -396,7 +548,7 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
396
548
|
const canvasY = (e.clientY - rect.top - v.y) / v.scale;
|
|
397
549
|
onCanvasContextMenu(e, canvasX, canvasY);
|
|
398
550
|
},
|
|
399
|
-
[containerRef, onCanvasContextMenu],
|
|
551
|
+
[annotationMode, containerRef, onCanvasContextMenu],
|
|
400
552
|
);
|
|
401
553
|
|
|
402
554
|
// ── Drag-and-drop files from filesystem ──
|
|
@@ -543,7 +695,11 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
543
695
|
height: '100%',
|
|
544
696
|
position: 'relative',
|
|
545
697
|
overflow: 'hidden',
|
|
546
|
-
cursor:
|
|
698
|
+
cursor: annotationTool === 'eraser'
|
|
699
|
+
? 'cell'
|
|
700
|
+
: annotationMode || draggingEdge.value || isLassoing.current
|
|
701
|
+
? 'crosshair'
|
|
702
|
+
: 'grab',
|
|
547
703
|
}}
|
|
548
704
|
>
|
|
549
705
|
{/* D4: CSS matrix(a,b,c,d,tx,ty) — scale uniformly (a=d=scale, b=c=0)
|
|
@@ -561,6 +717,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
561
717
|
>
|
|
562
718
|
<FocusFieldLayer />
|
|
563
719
|
<EdgeLayer nodes={nodes} edges={edges} />
|
|
720
|
+
<AnnotationLayer annotations={Array.from(annotations.value.values())} />
|
|
721
|
+
{draftAnnotation && draftAnnotation.points.length >= 2 && <AnnotationLayer annotations={[draftAnnotation]} />}
|
|
564
722
|
{worldNodes.map((node) => (
|
|
565
723
|
<CanvasNode key={node.id} node={node} onContextMenu={onNodeContextMenu}>
|
|
566
724
|
{renderNodeContent(node)}
|
|
@@ -579,6 +737,7 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
|
|
|
579
737
|
</svg>
|
|
580
738
|
)}
|
|
581
739
|
</div>
|
|
740
|
+
{annotationMode && <div class={`annotation-capture-layer${annotationTool === 'eraser' ? ' erasing' : ''}`} aria-hidden="true" />}
|
|
582
741
|
{lassoStyle && <div class="lasso-rect" style={lassoStyle} />}
|
|
583
742
|
{dropActive && (
|
|
584
743
|
<div class="drop-zone-overlay">
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
clearContextPins,
|
|
3
3
|
contextPinnedNodeIds,
|
|
4
|
+
hasOpenDockedContextPanel,
|
|
4
5
|
} from '../state/canvas-store';
|
|
5
6
|
import { attentionHistoryOpen } from '../state/attention-store';
|
|
6
7
|
|
|
7
8
|
export function ContextPinBar() {
|
|
8
9
|
const count = contextPinnedNodeIds.value.size;
|
|
9
|
-
if (count === 0 || attentionHistoryOpen.value) return null;
|
|
10
|
+
if (count === 0 || attentionHistoryOpen.value || hasOpenDockedContextPanel.value) return null;
|
|
10
11
|
|
|
11
12
|
return (
|
|
12
13
|
<div class="context-pin-bar">
|
|
@@ -14,6 +14,7 @@ interface PanZoomOptions {
|
|
|
14
14
|
viewport: Signal<ViewportState>;
|
|
15
15
|
onViewportChange: (v: ViewportState) => void;
|
|
16
16
|
onViewportCommit: (v: ViewportState) => void;
|
|
17
|
+
disabled?: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -23,7 +24,7 @@ interface PanZoomOptions {
|
|
|
23
24
|
* - Pointer drag on background: pan
|
|
24
25
|
* - Pinch (touch): zoom
|
|
25
26
|
*/
|
|
26
|
-
export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: PanZoomOptions) {
|
|
27
|
+
export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disabled = false }: PanZoomOptions) {
|
|
27
28
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
28
29
|
const isPanning = useRef(false);
|
|
29
30
|
const lastPointer = useRef({ x: 0, y: 0 });
|
|
@@ -42,6 +43,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
|
|
|
42
43
|
|
|
43
44
|
const handleWheel = useCallback(
|
|
44
45
|
(e: WheelEvent) => {
|
|
46
|
+
if (disabled) return;
|
|
45
47
|
e.preventDefault();
|
|
46
48
|
const v = viewport.value;
|
|
47
49
|
|
|
@@ -74,21 +76,23 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
|
|
|
74
76
|
scheduleViewportCommit(next);
|
|
75
77
|
}
|
|
76
78
|
},
|
|
77
|
-
[viewport, onViewportChange, scheduleViewportCommit],
|
|
79
|
+
[disabled, viewport, onViewportChange, scheduleViewportCommit],
|
|
78
80
|
);
|
|
79
81
|
|
|
80
82
|
const handlePointerDown = useCallback((e: PointerEvent) => {
|
|
83
|
+
if (disabled) return;
|
|
81
84
|
// Only pan when clicking the canvas background (not nodes)
|
|
82
85
|
const container = containerRef.current;
|
|
83
86
|
if (!container || e.target !== container) return;
|
|
84
87
|
isPanning.current = true;
|
|
85
88
|
lastPointer.current = { x: e.clientX, y: e.clientY };
|
|
86
89
|
container.setPointerCapture(e.pointerId);
|
|
87
|
-
}, []);
|
|
90
|
+
}, [disabled]);
|
|
88
91
|
|
|
89
92
|
const handlePointerMove = useCallback(
|
|
90
93
|
(e: PointerEvent) => {
|
|
91
94
|
if (!isPanning.current) return;
|
|
95
|
+
if (disabled) return;
|
|
92
96
|
const dx = e.clientX - lastPointer.current.x;
|
|
93
97
|
const dy = e.clientY - lastPointer.current.y;
|
|
94
98
|
lastPointer.current = { x: e.clientX, y: e.clientY };
|
|
@@ -96,7 +100,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
|
|
|
96
100
|
const v = viewport.value;
|
|
97
101
|
onViewportChange({ x: v.x + dx, y: v.y + dy, scale: v.scale });
|
|
98
102
|
},
|
|
99
|
-
[viewport, onViewportChange],
|
|
103
|
+
[disabled, viewport, onViewportChange],
|
|
100
104
|
);
|
|
101
105
|
|
|
102
106
|
const handlePointerUp = useCallback(() => {
|
|
@@ -109,6 +113,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
|
|
|
109
113
|
// Touch pinch
|
|
110
114
|
const handleTouchMove = useCallback(
|
|
111
115
|
(e: TouchEvent) => {
|
|
116
|
+
if (disabled) return;
|
|
112
117
|
if (e.touches.length !== 2) {
|
|
113
118
|
lastPinchDist.current = 0;
|
|
114
119
|
return;
|
|
@@ -142,7 +147,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
|
|
|
142
147
|
}
|
|
143
148
|
lastPinchDist.current = dist;
|
|
144
149
|
},
|
|
145
|
-
[viewport, onViewportChange, scheduleViewportCommit],
|
|
150
|
+
[disabled, viewport, onViewportChange, scheduleViewportCommit],
|
|
146
151
|
);
|
|
147
152
|
|
|
148
153
|
const handleTouchEnd = useCallback(() => {
|
package/src/client/icons.tsx
CHANGED
|
@@ -117,6 +117,28 @@ export function IconMoon(p: IconProps): JSX.Element {
|
|
|
117
117
|
);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** Pen stroke — canvas annotation mode */
|
|
121
|
+
export function IconPen(p: IconProps): JSX.Element {
|
|
122
|
+
return (
|
|
123
|
+
<Icon {...p}>
|
|
124
|
+
<path d="M3 13l2.8-.7L13 5.1 10.9 3 3.7 10.2 3 13Z" />
|
|
125
|
+
<path d="M9.8 4.1l2.1 2.1" />
|
|
126
|
+
<path d="M2.5 14h11" />
|
|
127
|
+
</Icon>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Eraser — remove canvas annotations */
|
|
132
|
+
export function IconEraser(p: IconProps): JSX.Element {
|
|
133
|
+
return (
|
|
134
|
+
<Icon {...p}>
|
|
135
|
+
<path d="M3 10.5 8.5 5a2 2 0 0 1 2.8 0l1.7 1.7a2 2 0 0 1 0 2.8L8.5 14H5.8L3 11.2Z" />
|
|
136
|
+
<path d="M7.5 6 12 10.5" />
|
|
137
|
+
<path d="M8.5 14H14" />
|
|
138
|
+
</Icon>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
120
142
|
/** Camera — snapshots */
|
|
121
143
|
export function IconSnapshot(p: IconProps): JSX.Element {
|
|
122
144
|
return (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { batch, computed, signal } from '@preact/signals';
|
|
2
|
-
import { isExcalidrawNode, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
|
|
2
|
+
import { isExcalidrawNode, type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
|
|
3
3
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
4
4
|
import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
|
|
5
5
|
|
|
@@ -11,6 +11,7 @@ function logCanvasStoreError(action: string, error: unknown): void {
|
|
|
11
11
|
export const viewport = signal<ViewportState>({ x: 0, y: 0, scale: 1 });
|
|
12
12
|
export const nodes = signal<Map<string, CanvasNodeState>>(new Map());
|
|
13
13
|
export const edges = signal<Map<string, CanvasEdge>>(new Map());
|
|
14
|
+
export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
|
|
14
15
|
export const activeNodeId = signal<string | null>(null);
|
|
15
16
|
export const connectionStatus = signal<ConnectionStatus>('connecting');
|
|
16
17
|
export const sessionId = signal<string>('');
|
|
@@ -258,6 +259,50 @@ export function removeEdgesForNode(nodeId: string): void {
|
|
|
258
259
|
if (changed) edges.value = next;
|
|
259
260
|
}
|
|
260
261
|
|
|
262
|
+
export function addAnnotation(annotation: CanvasAnnotation): void {
|
|
263
|
+
const next = new Map(annotations.value);
|
|
264
|
+
next.set(annotation.id, annotation);
|
|
265
|
+
annotations.value = next;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function removeAnnotation(id: string): void {
|
|
269
|
+
const next = new Map(annotations.value);
|
|
270
|
+
if (!next.delete(id)) return;
|
|
271
|
+
annotations.value = next;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function createAnnotationFromClient(input: {
|
|
275
|
+
points: CanvasAnnotation['points'];
|
|
276
|
+
color: string;
|
|
277
|
+
width: number;
|
|
278
|
+
label?: string;
|
|
279
|
+
}): Promise<{ ok: boolean }> {
|
|
280
|
+
try {
|
|
281
|
+
const res = await fetch('/api/canvas/annotation', {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
284
|
+
body: JSON.stringify(input),
|
|
285
|
+
});
|
|
286
|
+
return { ok: res.ok };
|
|
287
|
+
} catch (error) {
|
|
288
|
+
logCanvasStoreError('createAnnotationFromClient', error);
|
|
289
|
+
return { ok: false };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function removeAnnotationFromClient(id: string): Promise<{ ok: boolean }> {
|
|
294
|
+
try {
|
|
295
|
+
const res = await fetch(`/api/canvas/annotation/${encodeURIComponent(id)}`, {
|
|
296
|
+
method: 'DELETE',
|
|
297
|
+
});
|
|
298
|
+
if (res.ok) removeAnnotation(id);
|
|
299
|
+
return { ok: res.ok };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
logCanvasStoreError('removeAnnotationFromClient', error);
|
|
302
|
+
return { ok: false };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
261
306
|
export function resizeNode(id: string, size: { width: number; height: number }): void {
|
|
262
307
|
const existing = nodes.value.get(id);
|
|
263
308
|
if (!existing) return;
|
|
@@ -343,7 +388,7 @@ function commitViewportWithOptions(
|
|
|
343
388
|
}
|
|
344
389
|
|
|
345
390
|
export function applyServerCanvasLayout(
|
|
346
|
-
layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState },
|
|
391
|
+
layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState; annotations?: CanvasAnnotation[] },
|
|
347
392
|
options: { applyViewport?: boolean } = {},
|
|
348
393
|
): void {
|
|
349
394
|
const nextNodes = new Map<string, CanvasNodeState>();
|
|
@@ -360,6 +405,10 @@ export function applyServerCanvasLayout(
|
|
|
360
405
|
for (const edge of edgeSource) {
|
|
361
406
|
nextEdges.set(edge.id, edge);
|
|
362
407
|
}
|
|
408
|
+
const nextAnnotations = new Map<string, CanvasAnnotation>();
|
|
409
|
+
for (const annotation of layout.annotations ?? []) {
|
|
410
|
+
nextAnnotations.set(annotation.id, annotation);
|
|
411
|
+
}
|
|
363
412
|
|
|
364
413
|
const nextActiveNodeId =
|
|
365
414
|
activeNodeId.value !== null && nextNodes.has(activeNodeId.value) ? activeNodeId.value : null;
|
|
@@ -375,6 +424,7 @@ export function applyServerCanvasLayout(
|
|
|
375
424
|
maxZ = nextMaxZ;
|
|
376
425
|
nodes.value = nextNodes;
|
|
377
426
|
edges.value = nextEdges;
|
|
427
|
+
annotations.value = nextAnnotations;
|
|
378
428
|
activeNodeId.value = nextActiveNodeId;
|
|
379
429
|
expandedNodeId.value = nextExpandedNodeId;
|
|
380
430
|
if (!sameSetValues(selectedNodeIds.value, nextSelectedNodeIds)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { findOpenCanvasPosition } from '../utils/placement.js';
|
|
2
2
|
import { normalizeExtAppToolResult } from '../utils/ext-app-tool-result.js';
|
|
3
|
-
import type { CanvasEdge, CanvasNodeState } from '../types';
|
|
3
|
+
import type { CanvasAnnotation, CanvasEdge, CanvasNodeState } from '../types';
|
|
4
4
|
import {
|
|
5
5
|
activeNodeId,
|
|
6
6
|
addEdge,
|
|
@@ -310,6 +310,7 @@ function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
|
|
|
310
310
|
|| value === 'trace'
|
|
311
311
|
|| value === 'file'
|
|
312
312
|
|| value === 'image'
|
|
313
|
+
|| value === 'html'
|
|
313
314
|
|| value === 'group';
|
|
314
315
|
}
|
|
315
316
|
|
|
@@ -334,6 +335,12 @@ function parseCanvasSize(value: unknown): { width: number; height: number } | nu
|
|
|
334
335
|
return { width: size.width, height: size.height };
|
|
335
336
|
}
|
|
336
337
|
|
|
338
|
+
function parseCanvasRect(value: unknown): { x: number; y: number; width: number; height: number } | null {
|
|
339
|
+
const position = parseCanvasPosition(value);
|
|
340
|
+
const size = parseCanvasSize(value);
|
|
341
|
+
return position && size ? { ...position, ...size } : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
337
344
|
function parseCanvasNode(raw: Record<string, unknown>): CanvasNodeState | null {
|
|
338
345
|
if (typeof raw.id !== 'string' || !raw.id) return null;
|
|
339
346
|
if (!isCanvasNodeType(raw.type)) return null;
|
|
@@ -379,6 +386,28 @@ function parseCanvasEdge(raw: Record<string, unknown>): CanvasEdge | null {
|
|
|
379
386
|
};
|
|
380
387
|
}
|
|
381
388
|
|
|
389
|
+
function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation | null {
|
|
390
|
+
if (typeof raw.id !== 'string' || !raw.id) return null;
|
|
391
|
+
if (raw.type !== 'freehand') return null;
|
|
392
|
+
if (!Array.isArray(raw.points)) return null;
|
|
393
|
+
const points = raw.points
|
|
394
|
+
.map((point) => parseCanvasPosition(point))
|
|
395
|
+
.filter((point): point is { x: number; y: number } => point !== null);
|
|
396
|
+
const bounds = parseCanvasRect(raw.bounds);
|
|
397
|
+
if (points.length < 2 || !bounds) return null;
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
id: raw.id,
|
|
401
|
+
type: 'freehand',
|
|
402
|
+
points,
|
|
403
|
+
bounds,
|
|
404
|
+
color: typeof raw.color === 'string' ? raw.color : '#f97316',
|
|
405
|
+
width: typeof raw.width === 'number' ? raw.width : 4,
|
|
406
|
+
...(typeof raw.label === 'string' ? { label: raw.label } : {}),
|
|
407
|
+
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
382
411
|
// ── SSE event handlers ───────────────────────────────────────
|
|
383
412
|
function handleConnected(data: Record<string, unknown>): void {
|
|
384
413
|
sessionId.value = (data.sessionId as string) || '';
|
|
@@ -801,6 +830,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
|
|
|
801
830
|
| {
|
|
802
831
|
nodes?: Array<Record<string, unknown>>;
|
|
803
832
|
edges?: Array<Record<string, unknown>>;
|
|
833
|
+
annotations?: Array<Record<string, unknown>>;
|
|
804
834
|
viewport?: Record<string, unknown>;
|
|
805
835
|
}
|
|
806
836
|
| undefined;
|
|
@@ -814,6 +844,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
|
|
|
814
844
|
const serverEdges = Array.isArray(layout.edges)
|
|
815
845
|
? layout.edges.map(parseCanvasEdge).filter((edge): edge is CanvasEdge => edge !== null)
|
|
816
846
|
: Array.from(edges.value.values());
|
|
847
|
+
const serverAnnotations = Array.isArray(layout.annotations)
|
|
848
|
+
? layout.annotations.map(parseCanvasAnnotation).filter((annotation): annotation is CanvasAnnotation => annotation !== null)
|
|
849
|
+
: undefined;
|
|
817
850
|
const nextViewport = layout.viewport
|
|
818
851
|
? {
|
|
819
852
|
x: typeof layout.viewport.x === 'number' ? layout.viewport.x : 0,
|
|
@@ -827,6 +860,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
|
|
|
827
860
|
...(nextViewport ? { viewport: nextViewport } : {}),
|
|
828
861
|
nodes: serverNodes,
|
|
829
862
|
edges: serverEdges,
|
|
863
|
+
...(serverAnnotations ? { annotations: serverAnnotations } : {}),
|
|
830
864
|
}, { applyViewport: shouldApplyViewport });
|
|
831
865
|
|
|
832
866
|
syncAttentionFromSse({ event: 'canvas-layout-update', data });
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
--c-accent-hover: #6ECAFF;
|
|
51
51
|
--c-warn-hover: #f5d06b;
|
|
52
52
|
--c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
|
|
53
|
+
--c-annotation: #F4EFE6;
|
|
53
54
|
/* ── Non-color tokens ────────────────────────────────────── */
|
|
54
55
|
--font: "IBM Plex Sans", "SF Pro Text", "Avenir Next", system-ui, sans-serif;
|
|
55
56
|
--mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
|
|
@@ -109,6 +110,7 @@
|
|
|
109
110
|
--c-accent-hover: #1588CE;
|
|
110
111
|
--c-warn-hover: #dab040;
|
|
111
112
|
--c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(8, 21, 36, 0.02));
|
|
113
|
+
--c-annotation: #081524;
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
:root[data-theme="high-contrast"] {
|
|
@@ -162,6 +164,7 @@
|
|
|
162
164
|
--c-accent-hover: #33ffff;
|
|
163
165
|
--c-warn-hover: #ffff33;
|
|
164
166
|
--c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0));
|
|
167
|
+
--c-annotation: #ffff00;
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
* {
|
|
@@ -1471,6 +1474,28 @@ body,
|
|
|
1471
1474
|
z-index: 9998;
|
|
1472
1475
|
}
|
|
1473
1476
|
|
|
1477
|
+
.annotation-layer {
|
|
1478
|
+
position: absolute;
|
|
1479
|
+
inset: 0;
|
|
1480
|
+
width: 1px;
|
|
1481
|
+
height: 1px;
|
|
1482
|
+
overflow: visible;
|
|
1483
|
+
pointer-events: none;
|
|
1484
|
+
z-index: 45;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
.annotation-capture-layer {
|
|
1488
|
+
position: absolute;
|
|
1489
|
+
inset: 0;
|
|
1490
|
+
z-index: 9996;
|
|
1491
|
+
pointer-events: none;
|
|
1492
|
+
background: color-mix(in srgb, var(--c-accent) 5%, transparent);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
.annotation-capture-layer.erasing {
|
|
1496
|
+
background: color-mix(in srgb, var(--c-danger) 6%, transparent);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1474
1499
|
/* ── Drop Zone (file drag-and-drop) ─────────────────────────── */
|
|
1475
1500
|
.drop-zone-overlay {
|
|
1476
1501
|
position: absolute;
|
package/src/client/types.ts
CHANGED
|
@@ -41,6 +41,22 @@ export interface CanvasEdge {
|
|
|
41
41
|
animated?: boolean;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export interface CanvasAnnotationPoint {
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CanvasAnnotation {
|
|
50
|
+
id: string;
|
|
51
|
+
type: 'freehand';
|
|
52
|
+
points: CanvasAnnotationPoint[];
|
|
53
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
54
|
+
color: string;
|
|
55
|
+
width: number;
|
|
56
|
+
label?: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
|
|
45
61
|
|
|
46
62
|
// ── Shared constants for node type display ──────────────────
|
|
@@ -93,4 +109,5 @@ export interface CanvasLayout {
|
|
|
93
109
|
viewport: ViewportState;
|
|
94
110
|
nodes: CanvasNodeState[];
|
|
95
111
|
edges: CanvasEdge[];
|
|
112
|
+
annotations?: CanvasAnnotation[];
|
|
96
113
|
}
|