@bendyline/squisq-editor-react 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. package/src/tiptapBridge.ts +60 -33
@@ -0,0 +1,89 @@
1
+ /**
2
+ * SceneSideToolbar — placement wrapper for a `SceneBlockToolbar` beside an
3
+ * *inline* diagram / drawing / layout canvas.
4
+ *
5
+ * The toolbar normally floats as an always-open vertical column in the page's
6
+ * right gutter (`.squisq-scene-side-toolbar`, positioned at `left: 100%`). That
7
+ * only works when the gutter is wide enough to hold the 180px column; on a
8
+ * narrow editor — or in a host whose page fills most of the width — the column
9
+ * overhangs the editor's clipping edge and gets cut off.
10
+ *
11
+ * So we measure the real gutter (the gap between the canvas's right edge and
12
+ * the nearest horizontally-clipping ancestor) rather than guessing from a
13
+ * viewport breakpoint, since page width / centering / host chrome all move it:
14
+ * • Fits — render the column as-is (the common, wider case).
15
+ * • Clips — render the toolbar as a compact horizontal bar in normal flow
16
+ * above the canvas (`.squisq-scene-inline-toolbar`). It stays visible and
17
+ * is never clipped; it just costs a small strip of vertical space.
18
+ *
19
+ * A normal-flow bar is used rather than an on-demand overlay popover because
20
+ * absolutely-positioned content overlapping the React Flow canvas fails to
21
+ * paint in some hosts (the diagram's own maximize overlay hits the same wall),
22
+ * whereas in-flow content always renders.
23
+ *
24
+ * Only the inline placement adapts. Maximized mode has a full screen to reserve
25
+ * a static right column, so the widgets render the bare
26
+ * `.squisq-scene-side-toolbar` there and never mount this wrapper.
27
+ */
28
+
29
+ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
30
+
31
+ /**
32
+ * Horizontal room the gutter must have to host the open column, kept in sync
33
+ * with `.squisq-scene-side-toolbar` in scene.css (12px `margin-left` + 180px
34
+ * `width`) plus a few px of breathing room so we fall back to the bar just
35
+ * before the column would touch the clip edge.
36
+ */
37
+ const SIDE_TOOLBAR_FIT_PX = 12 + 180 + 4;
38
+
39
+ interface SceneSideToolbarProps {
40
+ /** The `SceneBlockToolbar` element built by the host widget. */
41
+ children: ReactNode;
42
+ }
43
+
44
+ export function SceneSideToolbar({ children }: SceneSideToolbarProps) {
45
+ const wrapRef = useRef<HTMLDivElement>(null);
46
+ const [collapsed, setCollapsed] = useState(false);
47
+
48
+ // Measure whether the right gutter can host the column. The column is
49
+ // absolutely positioned at the shell's right edge, so its clipping depends on
50
+ // `shellRight` vs. the nearest ancestor that clips horizontally — recomputed
51
+ // whenever the editor body resizes (window resize, outline toggle, etc.).
52
+ // Switching to the in-flow bar changes shell *height*, not `shellRight`, so
53
+ // there's no measure↔collapse loop.
54
+ useLayoutEffect(() => {
55
+ const wrap = wrapRef.current;
56
+ if (!wrap) return;
57
+ const shell = wrap.closest('.squisq-scene-shell') as HTMLElement | null;
58
+ const clip = wrap.closest('.squisq-editor-content') as HTMLElement | null;
59
+ const measure = () => {
60
+ if (!shell) return;
61
+ const shellRight = shell.getBoundingClientRect().right;
62
+ const clipRight = clip
63
+ ? clip.getBoundingClientRect().right
64
+ : document.documentElement.clientWidth;
65
+ setCollapsed(clipRight - shellRight < SIDE_TOOLBAR_FIT_PX);
66
+ };
67
+ measure();
68
+ const ro = new ResizeObserver(measure);
69
+ const target = clip ?? shell;
70
+ if (target) ro.observe(target);
71
+ if (shell && shell !== target) ro.observe(shell);
72
+ window.addEventListener('resize', measure);
73
+ return () => {
74
+ ro.disconnect();
75
+ window.removeEventListener('resize', measure);
76
+ };
77
+ }, []);
78
+
79
+ return (
80
+ <div
81
+ ref={wrapRef}
82
+ className={collapsed ? 'squisq-scene-inline-toolbar' : 'squisq-scene-side-toolbar'}
83
+ >
84
+ {children}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ export default SceneSideToolbar;
@@ -16,7 +16,7 @@
16
16
  import type { MarkerStyle } from '@bendyline/squisq/schemas';
17
17
  import { connectorPath, markerPath } from '@bendyline/squisq/doc';
18
18
  import type { SceneEdge } from '../commands/SceneCommand';
19
- import { boxesOf, edgePoint } from './edgeGeometry';
19
+ import { boxesOf, edgeEndpoints } from './edgeGeometry';
20
20
  import type { EdgeNodeBox } from './edgeGeometry';
21
21
 
22
22
  interface DiagramEdgesProps {
@@ -81,8 +81,9 @@ export function DiagramEdges({
81
81
  const a = boxById.get(edge.source);
82
82
  const b = boxById.get(edge.target);
83
83
  if (!a || !b) return null;
84
- const start = edgePoint(a, b);
85
- const end = edgePoint(b, a);
84
+ const snapped = edgeEndpoints(nodes, edge.source, edge.target);
85
+ if (!snapped) return null;
86
+ const { start, end } = snapped;
86
87
  const d = connectorPath(edge.routing ?? defaultRouting, start, end);
87
88
  const startMarker = edge.startMarker ?? 'none';
88
89
  const endMarker = edge.endMarker ?? 'arrow';
@@ -8,6 +8,11 @@
8
8
  */
9
9
 
10
10
  import { NODE_WIDTH, NODE_HEIGHT } from './nodeCard';
11
+ import {
12
+ nearestSnapPoint as nearestCoreSnapPoint,
13
+ snapEndpoints as coreSnapEndpoints,
14
+ type ConnectorSnapPoint,
15
+ } from '@bendyline/squisq/doc';
11
16
 
12
17
  /** Minimal box an edge needs from a node/shape (id + bounds). */
13
18
  export interface EdgeNodeBox {
@@ -49,21 +54,35 @@ export function edgePoint(from: NodeBox, to: NodeBox): { x: number; y: number }
49
54
  return { x: from.cx + dx * s, y: from.cy + dy * s };
50
55
  }
51
56
 
57
+ function boxFor(nodes: readonly EdgeNodeBox[], id: string): NodeBox | null {
58
+ return boxesOf(nodes).get(id) ?? null;
59
+ }
60
+
61
+ /** Nearest stable connector port on the named node/shape to `point`. */
62
+ export function snapPointToward(
63
+ nodes: readonly EdgeNodeBox[],
64
+ id: string,
65
+ point: { x: number; y: number },
66
+ ): ConnectorSnapPoint | null {
67
+ const box = boxFor(nodes, id);
68
+ return box ? nearestCoreSnapPoint(box, point) : null;
69
+ }
70
+
52
71
  /**
53
- * Clipped start/end points of the edge `source`→`target`, or null when an
54
- * endpoint shape is missing. Used by the renderer and by a tool hit-testing
72
+ * Snapped start/end points of the edge `source`→`target`, or null when an
73
+ * endpoint shape is missing. Used by the renderer and by tool hit-testing
55
74
  * endpoint handles against the same geometry the renderer draws.
56
75
  */
57
76
  export function edgeEndpoints(
58
77
  nodes: readonly EdgeNodeBox[],
59
78
  source: string,
60
79
  target: string,
61
- ): { start: { x: number; y: number }; end: { x: number; y: number } } | null {
80
+ ): { start: ConnectorSnapPoint; end: ConnectorSnapPoint } | null {
62
81
  const boxes = boxesOf(nodes);
63
82
  const a = boxes.get(source);
64
83
  const b = boxes.get(target);
65
84
  if (!a || !b) return null;
66
- return { start: edgePoint(a, b), end: edgePoint(b, a) };
85
+ return coreSnapEndpoints(a, b);
67
86
  }
68
87
 
69
88
  export function straightPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
@@ -20,6 +20,28 @@
20
20
  overflow: hidden;
21
21
  }
22
22
 
23
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-shell {
24
+ --squisq-surface: #0f172a;
25
+ --squisq-surface-hover: #1e293b;
26
+ --squisq-border: #475569;
27
+ --squisq-text: #f8fafc;
28
+ --squisq-text-muted: #94a3b8;
29
+ }
30
+
31
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-root,
32
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-inline,
33
+ .squisq-editor-shell[data-theme='dark'] .squisq-diagram-inline {
34
+ background: #0f172a;
35
+ border-color: #475569;
36
+ }
37
+
38
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-layer--shape,
39
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-layer--path,
40
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-layer--text,
41
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-extras {
42
+ filter: invert(1) hue-rotate(180deg) saturate(0.9);
43
+ }
44
+
23
45
  .squisq-scene-viewport {
24
46
  display: block;
25
47
  width: 100%;
@@ -111,6 +133,18 @@
111
133
  pointer-events: none;
112
134
  }
113
135
 
136
+ .squisq-scene-connection-point {
137
+ fill: #ffffff;
138
+ stroke: #6366f1;
139
+ stroke-width: 2;
140
+ vector-effect: non-scaling-stroke;
141
+ pointer-events: none;
142
+ }
143
+
144
+ .squisq-scene-connection-point--active {
145
+ fill: #6366f1;
146
+ }
147
+
114
148
  /* ── Toolbar ──────────────────────────────────────────────── */
115
149
 
116
150
  .squisq-scene-toolbar {
@@ -261,6 +295,46 @@
261
295
  color: #b91c1c;
262
296
  }
263
297
 
298
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-toolbar,
299
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-block-tools,
300
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-block-actions,
301
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-block-props {
302
+ background: #111827;
303
+ border-color: #475569;
304
+ color: #e5e7eb;
305
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.32);
306
+ }
307
+
308
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-tool,
309
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-action {
310
+ background: #111827;
311
+ color: #e5e7eb;
312
+ }
313
+
314
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-tool:hover,
315
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-action:hover:not(:disabled) {
316
+ background: #1f2937;
317
+ border-color: #64748b;
318
+ color: #f8fafc;
319
+ }
320
+
321
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-tool--active,
322
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-tool--active:hover {
323
+ background: #312e81;
324
+ border-color: #818cf8;
325
+ color: #f8fafc;
326
+ }
327
+
328
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-action--danger {
329
+ color: #fca5a5;
330
+ }
331
+
332
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-action--danger:hover:not(:disabled) {
333
+ background: #450a0a;
334
+ border-color: #f87171;
335
+ color: #fecaca;
336
+ }
337
+
264
338
  /* Maximized overlay: a flex-filling canvas with the toolbar as a right-hand
265
339
  column (full screen has room to reserve it). */
266
340
  .squisq-scene-block-max {
@@ -303,8 +377,8 @@
303
377
  overflow-y: auto;
304
378
  }
305
379
 
306
- /* Re-flow the (normally horizontal, right-aligned) toolbar into a top-aligned
307
- vertical column whose button groups wrap within the 180px width. */
380
+ /* Re-flow the (normally horizontal, right-aligned) gutter toolbar into a
381
+ top-aligned vertical column whose button groups wrap within the 180px. */
308
382
  .squisq-scene-side-toolbar .squisq-scene-block-toolbar {
309
383
  flex-direction: column;
310
384
  align-items: stretch;
@@ -326,6 +400,23 @@
326
400
  right: calc(100% + 6px);
327
401
  }
328
402
 
403
+ /* ── Narrow-width fallback bar ─────────────────────────────────
404
+ When SceneSideToolbar measures that the right gutter can't hold the 180px
405
+ column, it renders the same toolbar as an in-flow horizontal bar above the
406
+ canvas instead. Normal flow (not an overlay) so it always paints and is
407
+ never clipped; it just keeps the toolbar's natural horizontal layout and
408
+ wraps on very narrow widths. */
409
+ .squisq-scene-inline-toolbar {
410
+ display: flex;
411
+ margin-bottom: 8px;
412
+ }
413
+ .squisq-scene-inline-toolbar .squisq-scene-block-toolbar {
414
+ flex-wrap: wrap;
415
+ justify-content: flex-start;
416
+ width: 100%;
417
+ margin: 0;
418
+ }
419
+
329
420
  /* ── Maximize button ──────────────────────────────────────── */
330
421
 
331
422
  .squisq-scene-maximize-btn {
@@ -354,6 +445,19 @@
354
445
  color: #0f172a;
355
446
  }
356
447
 
448
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-maximize-btn {
449
+ background: #111827;
450
+ border-color: #475569;
451
+ color: #e5e7eb;
452
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.32);
453
+ }
454
+
455
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-maximize-btn:hover {
456
+ background: #1f2937;
457
+ border-color: #64748b;
458
+ color: #f8fafc;
459
+ }
460
+
357
461
  /* ── Diagram node card (used by nodeCard.tsx) ─────────────── */
358
462
 
359
463
  .squisq-scene-node-card {
@@ -502,6 +606,35 @@
502
606
  font-weight: 700;
503
607
  }
504
608
 
609
+ .squisq-editor-shell[data-theme='dark'] .squisq-shape-palette {
610
+ background: #111827;
611
+ color: #e5e7eb;
612
+ border-color: #475569;
613
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.42);
614
+ }
615
+
616
+ .squisq-editor-shell[data-theme='dark'] .squisq-shape-palette-search,
617
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-prop-select {
618
+ background: #0f172a;
619
+ color: #e5e7eb;
620
+ border-color: #475569;
621
+ }
622
+
623
+ .squisq-editor-shell[data-theme='dark'] .squisq-shape-palette-item:hover,
624
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-prop-clear:hover {
625
+ background: #1f2937;
626
+ border-color: #64748b;
627
+ }
628
+
629
+ .squisq-editor-shell[data-theme='dark'] .squisq-shape-thumb,
630
+ .squisq-editor-shell[data-theme='dark'] .squisq-shape-thumb-line {
631
+ stroke: #e5e7eb;
632
+ }
633
+
634
+ .squisq-editor-shell[data-theme='dark'] .squisq-shape-thumb-text {
635
+ fill: #e5e7eb;
636
+ }
637
+
505
638
  /* Per-selection style controls, inline in the contextual toolbar (Fill /
506
639
  Stroke / Color swatches + connector dropdowns) — styled as a pill that
507
640
  matches the tools/actions groups. */
@@ -566,6 +699,13 @@
566
699
  background: #f1f5f9;
567
700
  }
568
701
 
702
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-prop-color,
703
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-prop-clear {
704
+ background: #0f172a;
705
+ color: #e5e7eb;
706
+ border-color: #475569;
707
+ }
708
+
569
709
  /* ── Inline widget host (mirrors squisq-diagram-inline) ──── */
570
710
 
571
711
  .squisq-scene-widget-host {
@@ -13,14 +13,21 @@
13
13
 
14
14
  import { createElement, type JSX } from 'react';
15
15
  import type { SceneTool, SceneToolContext } from './SceneTool';
16
- import { nodeIdFromCardLayerId, NODE_WIDTH, NODE_HEIGHT } from '../layers/nodeCard';
16
+ import { nodeIdFromCardLayerId } from '../layers/nodeCard';
17
+ import {
18
+ curvedPath,
19
+ edgeEndpoints,
20
+ snapPointToward,
21
+ type EdgeNodeBox,
22
+ } from '../layers/edgeGeometry';
17
23
 
18
24
  interface ConnectState {
19
25
  sourceNodeId: string;
20
- /** Source node center in viewport units (cached at drag start). */
21
- sourceCenter: { x: number; y: number };
22
- /** Current pointer position in viewport units. */
23
- current: { x: number; y: number };
26
+ /** Source node/card box in viewport units (cached at drag start). */
27
+ sourceBox: EdgeNodeBox;
28
+ /** Preview start/end in viewport units. */
29
+ start: { x: number; y: number };
30
+ end: { x: number; y: number };
24
31
  /** Currently-hovered target node id, if any (for highlight + drop). */
25
32
  hoveredTargetId: string | null;
26
33
  }
@@ -32,13 +39,73 @@ function pointerFromEvent(e: React.PointerEvent): { sx: number; sy: number } {
32
39
  return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
33
40
  }
34
41
 
35
- function nodeCenterFromCard(
36
- ctx: SceneToolContext,
37
- nodeId: string,
38
- ): { x: number; y: number } | null {
42
+ function nodeBoxFromCard(ctx: SceneToolContext, nodeId: string): EdgeNodeBox | null {
39
43
  const card = ctx.hitItems.find((it) => it.id === `node-card-${nodeId}`);
40
44
  if (!card) return null;
41
- return { x: card.bounds.x + NODE_WIDTH / 2, y: card.bounds.y + NODE_HEIGHT / 2 };
45
+ return {
46
+ id: nodeId,
47
+ x: card.bounds.x,
48
+ y: card.bounds.y,
49
+ width: card.bounds.width,
50
+ height: card.bounds.height,
51
+ };
52
+ }
53
+
54
+ function nodeBoxes(ctx: SceneToolContext): EdgeNodeBox[] {
55
+ const boxes: EdgeNodeBox[] = [];
56
+ for (const it of ctx.hitItems) {
57
+ if (!it.id.startsWith('node-card-')) continue;
58
+ const nodeId = nodeIdFromCardLayerId(it.id);
59
+ if (!nodeId) continue;
60
+ boxes.push({
61
+ id: nodeId,
62
+ x: it.bounds.x,
63
+ y: it.bounds.y,
64
+ width: it.bounds.width,
65
+ height: it.bounds.height,
66
+ });
67
+ }
68
+ return boxes;
69
+ }
70
+
71
+ function connectionPoints(box: EdgeNodeBox): Array<{ x: number; y: number }> {
72
+ const width = box.width ?? 0;
73
+ const height = box.height ?? 0;
74
+ const left = box.x;
75
+ const right = box.x + width;
76
+ const top = box.y;
77
+ const bottom = box.y + height;
78
+ const centerX = box.x + width / 2;
79
+ const centerY = box.y + height / 2;
80
+ return [
81
+ { x: centerX, y: top },
82
+ { x: right, y: centerY },
83
+ { x: centerX, y: bottom },
84
+ { x: left, y: centerY },
85
+ { x: left, y: top },
86
+ { x: right, y: top },
87
+ { x: right, y: bottom },
88
+ { x: left, y: bottom },
89
+ ];
90
+ }
91
+
92
+ function updatePreview(
93
+ sourceBox: EdgeNodeBox,
94
+ current: { x: number; y: number },
95
+ targetBox: EdgeNodeBox | null,
96
+ ): { start: { x: number; y: number }; end: { x: number; y: number } } {
97
+ if (targetBox) {
98
+ return (
99
+ edgeEndpoints([sourceBox, targetBox], sourceBox.id, targetBox.id) ?? {
100
+ start: current,
101
+ end: current,
102
+ }
103
+ );
104
+ }
105
+ return {
106
+ start: snapPointToward([sourceBox], sourceBox.id, current) ?? current,
107
+ end: current,
108
+ };
42
109
  }
43
110
 
44
111
  export const ConnectTool: SceneTool = {
@@ -46,6 +113,7 @@ export const ConnectTool: SceneTool = {
46
113
  label: 'Connect',
47
114
  cursor: 'crosshair',
48
115
  shortcut: 'c',
116
+ hideSelectionHandles: true,
49
117
 
50
118
  onPointerDown(e, ctx) {
51
119
  if (e.button !== 0) return;
@@ -55,12 +123,14 @@ export const ConnectTool: SceneTool = {
55
123
  if (!hitId) return;
56
124
  const nodeId = nodeIdFromCardLayerId(hitId);
57
125
  if (!nodeId) return;
58
- const center = nodeCenterFromCard(ctx, nodeId);
59
- if (!center) return;
126
+ const sourceBox = nodeBoxFromCard(ctx, nodeId);
127
+ if (!sourceBox) return;
128
+ const preview = updatePreview(sourceBox, v, null);
60
129
  state = {
61
130
  sourceNodeId: nodeId,
62
- sourceCenter: center,
63
- current: v,
131
+ sourceBox,
132
+ start: preview.start,
133
+ end: preview.end,
64
134
  hoveredTargetId: null,
65
135
  };
66
136
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -71,10 +141,14 @@ export const ConnectTool: SceneTool = {
71
141
  if (!state) return;
72
142
  const { sx, sy } = pointerFromEvent(e);
73
143
  const v = ctx.screenToViewport(sx, sy);
74
- state.current = v;
75
144
  const hitId = ctx.hit(v);
76
145
  const nodeId = hitId ? nodeIdFromCardLayerId(hitId) : null;
77
- state.hoveredTargetId = nodeId && nodeId !== state.sourceNodeId ? nodeId : null;
146
+ const targetId = nodeId && nodeId !== state.sourceNodeId ? nodeId : null;
147
+ const targetBox = targetId ? nodeBoxFromCard(ctx, targetId) : null;
148
+ const preview = updatePreview(state.sourceBox, v, targetBox);
149
+ state.start = preview.start;
150
+ state.end = preview.end;
151
+ state.hoveredTargetId = targetBox?.id ?? null;
78
152
  },
79
153
 
80
154
  onPointerUp(e, ctx) {
@@ -87,17 +161,32 @@ export const ConnectTool: SceneTool = {
87
161
  state = null;
88
162
  },
89
163
 
90
- renderOverlay(): JSX.Element | null {
91
- if (!state) return null;
92
- const { sourceCenter: a, current: b } = state;
93
- const dx = b.x - a.x;
94
- const cp = Math.max(40, Math.abs(dx) / 2);
95
- // Cubic bezier matching diagramBlock.ts's `curved` edge style so the
96
- // preview previews exactly what will render after commit.
97
- const d = `M ${a.x} ${a.y} C ${a.x + cp} ${a.y}, ${b.x - cp} ${b.y}, ${b.x} ${b.y}`;
164
+ renderOverlay(ctx): JSX.Element | null {
165
+ const points = nodeBoxes(ctx).flatMap((box) =>
166
+ connectionPoints(box).map((point, index) =>
167
+ createElement('circle', {
168
+ key: `${box.id}-${index}`,
169
+ cx: point.x,
170
+ cy: point.y,
171
+ r: 4,
172
+ className:
173
+ state?.hoveredTargetId === box.id
174
+ ? 'squisq-scene-connection-point squisq-scene-connection-point--active'
175
+ : 'squisq-scene-connection-point',
176
+ }),
177
+ ),
178
+ );
179
+
180
+ if (!state) {
181
+ return points.length ? createElement('g', { key: 'connection-points' }, ...points) : null;
182
+ }
183
+
184
+ const { start: a, end: b } = state;
185
+ const d = curvedPath(a, b);
98
186
  return createElement(
99
187
  'g',
100
188
  { key: 'connect-preview' },
189
+ ...points,
101
190
  createElement('path', { d, className: 'squisq-scene-connect-preview' }),
102
191
  // Optional: a dot at the endpoint so the user sees the snap target.
103
192
  createElement('circle', {