@bendyline/squisq-editor-react 1.6.1 → 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 (44) hide show
  1. package/dist/index.d.ts +87 -41
  2. package/dist/index.js +8263 -6705
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +841 -91
  5. package/package.json +4 -4
  6. package/src/BlockPropertiesPopover.tsx +23 -7
  7. package/src/EditorShell.tsx +65 -20
  8. package/src/MediaBin.tsx +171 -28
  9. package/src/PreviewControls.tsx +177 -44
  10. package/src/PreviewPanel.tsx +2 -0
  11. package/src/TemplateAnnotation.ts +22 -7
  12. package/src/TemplateContentPreview.tsx +56 -0
  13. package/src/TemplatePicker.tsx +295 -128
  14. package/src/ThemeCustomizerPanel.tsx +22 -15
  15. package/src/Toolbar.tsx +527 -207
  16. package/src/TransitionPicker.tsx +8 -1
  17. package/src/ViewSwitcher.tsx +4 -4
  18. package/src/WysiwygEditor.tsx +45 -3
  19. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  20. package/src/__tests__/editorShellProps.test.tsx +268 -1
  21. package/src/__tests__/headingTransition.test.ts +59 -9
  22. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  23. package/src/__tests__/mediaReferences.test.ts +82 -0
  24. package/src/__tests__/previewControls.test.tsx +94 -1
  25. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  26. package/src/__tests__/templateContentPreview.test.ts +101 -0
  27. package/src/__tests__/tiptapBridge.test.ts +47 -0
  28. package/src/diagram/DiagramWidget.tsx +6 -4
  29. package/src/headingTransition.ts +96 -21
  30. package/src/index.ts +2 -1
  31. package/src/mediaReferences.ts +299 -0
  32. package/src/scene/Scene.tsx +53 -7
  33. package/src/scene/SceneBlockWidget.tsx +6 -3
  34. package/src/scene/SceneSelection.tsx +19 -15
  35. package/src/scene/SceneSideToolbar.tsx +89 -0
  36. package/src/scene/layers/DiagramEdges.tsx +4 -3
  37. package/src/scene/layers/edgeGeometry.ts +23 -4
  38. package/src/scene/scene.css +142 -2
  39. package/src/scene/tools/ConnectTool.ts +113 -24
  40. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  41. package/src/scene/tools/SceneTool.ts +2 -0
  42. package/src/styles/editor.css +862 -101
  43. package/src/templateContentPreviewResolver.ts +353 -0
  44. package/src/tiptapBridge.ts +60 -33
@@ -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', {
@@ -16,6 +16,12 @@
16
16
  import { createElement, type JSX } from 'react';
17
17
  import type { SceneTool, SceneToolContext } from './SceneTool';
18
18
  import { shapeIdFromLayerId, isPrimaryShapeLayer } from '../layers/shapeLayers';
19
+ import {
20
+ edgeEndpoints,
21
+ snapPointToward,
22
+ straightPath,
23
+ type EdgeNodeBox,
24
+ } from '../layers/edgeGeometry';
19
25
 
20
26
  /** A grab on the selected connector's endpoint handle. */
21
27
  export interface EndpointHit {
@@ -37,8 +43,9 @@ type DragState =
37
43
  | {
38
44
  mode: 'new';
39
45
  sourceId: string;
40
- anchor: { x: number; y: number };
41
- current: { x: number; y: number };
46
+ sourceBox: EdgeNodeBox;
47
+ start: { x: number; y: number };
48
+ end: { x: number; y: number };
42
49
  hovered: string | null;
43
50
  }
44
51
  | {
@@ -46,8 +53,9 @@ type DragState =
46
53
  connectorId: string;
47
54
  end: 'from' | 'to';
48
55
  fixedShapeId: string;
49
- anchor: { x: number; y: number };
50
- current: { x: number; y: number };
56
+ fixedBox: EdgeNodeBox;
57
+ start: { x: number; y: number };
58
+ endPoint: { x: number; y: number };
51
59
  hovered: string | null;
52
60
  };
53
61
 
@@ -59,10 +67,16 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
59
67
  return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
60
68
  }
61
69
 
62
- function shapeCenter(ctx: SceneToolContext, shapeId: string): { x: number; y: number } | null {
70
+ function shapeBox(ctx: SceneToolContext, shapeId: string): EdgeNodeBox | null {
63
71
  const it = ctx.hitItems.find((h) => h.id === `dshape-${shapeId}`);
64
72
  if (!it) return null;
65
- return { x: it.bounds.x + it.bounds.width / 2, y: it.bounds.y + it.bounds.height / 2 };
73
+ return {
74
+ id: shapeId,
75
+ x: it.bounds.x,
76
+ y: it.bounds.y,
77
+ width: it.bounds.width,
78
+ height: it.bounds.height,
79
+ };
66
80
  }
67
81
 
68
82
  function shapeAt(ctx: SceneToolContext, v: { x: number; y: number }): string | null {
@@ -71,6 +85,45 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
71
85
  return shapeIdFromLayerId(hitId);
72
86
  }
73
87
 
88
+ function previewNew(
89
+ sourceBox: EdgeNodeBox,
90
+ current: { x: number; y: number },
91
+ targetBox: EdgeNodeBox | null,
92
+ ): { start: { x: number; y: number }; end: { x: number; y: number } } {
93
+ if (targetBox) {
94
+ return (
95
+ edgeEndpoints([sourceBox, targetBox], sourceBox.id, targetBox.id) ?? {
96
+ start: current,
97
+ end: current,
98
+ }
99
+ );
100
+ }
101
+ return {
102
+ start: snapPointToward([sourceBox], sourceBox.id, current) ?? current,
103
+ end: current,
104
+ };
105
+ }
106
+
107
+ function previewRetarget(
108
+ fixedBox: EdgeNodeBox,
109
+ movedEnd: 'from' | 'to',
110
+ current: { x: number; y: number },
111
+ targetBox: EdgeNodeBox | null,
112
+ ): { start: { x: number; y: number }; end: { x: number; y: number } } {
113
+ if (targetBox) {
114
+ const source = movedEnd === 'from' ? targetBox : fixedBox;
115
+ const target = movedEnd === 'from' ? fixedBox : targetBox;
116
+ return (
117
+ edgeEndpoints([source, target], source.id, target.id) ?? {
118
+ start: current,
119
+ end: current,
120
+ }
121
+ );
122
+ }
123
+ const fixed = snapPointToward([fixedBox], fixedBox.id, current) ?? current;
124
+ return movedEnd === 'from' ? { start: current, end: fixed } : { start: fixed, end: current };
125
+ }
126
+
74
127
  return {
75
128
  id: 'connect',
76
129
  label: 'Connect',
@@ -85,14 +138,17 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
85
138
  // Endpoint handle of the selected connector → re-target gesture.
86
139
  const ep = options.endpointAt?.(v);
87
140
  if (ep) {
88
- const anchor = shapeCenter(ctx, ep.fixedShapeId) ?? v;
141
+ const fixedBox = shapeBox(ctx, ep.fixedShapeId);
142
+ if (!fixedBox) return;
143
+ const preview = previewRetarget(fixedBox, ep.end, v, null);
89
144
  drag = {
90
145
  mode: 'retarget',
91
146
  connectorId: ep.connectorId,
92
147
  end: ep.end,
93
148
  fixedShapeId: ep.fixedShapeId,
94
- anchor,
95
- current: v,
149
+ fixedBox,
150
+ start: preview.start,
151
+ endPoint: preview.end,
96
152
  hovered: null,
97
153
  };
98
154
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -103,11 +159,15 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
103
159
  // Otherwise start a new connection from the shape under the pointer.
104
160
  const src = shapeAt(ctx, v);
105
161
  if (!src) return;
162
+ const sourceBox = shapeBox(ctx, src);
163
+ if (!sourceBox) return;
164
+ const preview = previewNew(sourceBox, v, null);
106
165
  drag = {
107
166
  mode: 'new',
108
167
  sourceId: src,
109
- anchor: shapeCenter(ctx, src) ?? v,
110
- current: v,
168
+ sourceBox,
169
+ start: preview.start,
170
+ end: preview.end,
111
171
  hovered: null,
112
172
  };
113
173
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -118,10 +178,20 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
118
178
  if (!drag) return;
119
179
  const { sx, sy } = pointer(e);
120
180
  const v = ctx.screenToViewport(sx, sy);
121
- drag.current = v;
122
181
  const tgt = shapeAt(ctx, v);
123
182
  const exclude = drag.mode === 'new' ? drag.sourceId : drag.fixedShapeId;
124
- drag.hovered = tgt && tgt !== exclude ? tgt : null;
183
+ const targetId = tgt && tgt !== exclude ? tgt : null;
184
+ const targetBox = targetId ? shapeBox(ctx, targetId) : null;
185
+ if (drag.mode === 'new') {
186
+ const preview = previewNew(drag.sourceBox, v, targetBox);
187
+ drag.start = preview.start;
188
+ drag.end = preview.end;
189
+ } else {
190
+ const preview = previewRetarget(drag.fixedBox, drag.end, v, targetBox);
191
+ drag.start = preview.start;
192
+ drag.endPoint = preview.end;
193
+ }
194
+ drag.hovered = targetBox?.id ?? null;
125
195
  },
126
196
 
127
197
  onPointerUp(e, ctx) {
@@ -139,13 +209,13 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
139
209
 
140
210
  renderOverlay(): JSX.Element | null {
141
211
  if (!drag) return null;
142
- const a = drag.anchor;
143
- const b = drag.current;
212
+ const a = drag.start;
213
+ const b = drag.mode === 'new' ? drag.end : drag.endPoint;
144
214
  return createElement(
145
215
  'g',
146
216
  { key: 'drawing-connect-preview' },
147
217
  createElement('path', {
148
- d: `M ${a.x} ${a.y} L ${b.x} ${b.y}`,
218
+ d: straightPath(a, b),
149
219
  className: 'squisq-scene-connect-preview',
150
220
  fill: 'none',
151
221
  }),
@@ -55,6 +55,8 @@ export interface SceneTool {
55
55
  cursor: string;
56
56
  /** Optional keyboard accelerator (single-character, lowercase). */
57
57
  shortcut?: string;
58
+ /** Hide resize handles on selected layers while this tool is active. */
59
+ hideSelectionHandles?: boolean;
58
60
  /**
59
61
  * Optional icon — JSX rendered inside a fixed-size toolbar button. Keep
60
62
  * it light (a single SVG path is ideal); the toolbar wrapper handles