@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
@@ -0,0 +1,299 @@
1
+ import { splitKeyValueToken, tokenizeAttrTokens } from '@bendyline/squisq/markdown';
2
+
3
+ interface MediaReferenceRange {
4
+ start: number;
5
+ end: number;
6
+ }
7
+
8
+ interface MarkdownReferenceMatch {
9
+ destination: string;
10
+ range: MediaReferenceRange;
11
+ }
12
+
13
+ const MEDIA_REFERENCE_PARAM_KEYS = new Set([
14
+ 'audio',
15
+ 'file',
16
+ 'href',
17
+ 'image',
18
+ 'imagesrc',
19
+ 'path',
20
+ 'poster',
21
+ 'src',
22
+ 'url',
23
+ 'video',
24
+ ]);
25
+
26
+ const HTML_MEDIA_ATTRS = ['src', 'href', 'poster'] as const;
27
+
28
+ /**
29
+ * Collect media references from the document source. Used by the Files panel
30
+ * to distinguish stored files that are actually referenced by the document
31
+ * from files that only exist in the bin.
32
+ */
33
+ export function collectMediaReferencesFromMarkdown(source: string): ReadonlySet<string> {
34
+ const refs = new Set<string>();
35
+ collectMarkdownInlineReferences(source, refs);
36
+ collectHtmlAttributeReferences(source, refs);
37
+ collectAnnotationReferences(source, refs);
38
+ return refs;
39
+ }
40
+
41
+ /**
42
+ * Remove markdown/html references to a media asset without reparsing and
43
+ * reserializing the whole document. This targets the ref shapes the editor
44
+ * emits for uploaded files while preserving unrelated author formatting.
45
+ */
46
+ export function removeMediaReferencesFromMarkdown(source: string, mediaPath: string): string {
47
+ if (!mediaPath) return source;
48
+ const withoutMarkdownRefs = removeMarkdownInlineReferences(source, mediaPath);
49
+ const withoutImageTags = removeHtmlTagsByAttribute(withoutMarkdownRefs, 'img', 'src', mediaPath);
50
+ return removeHtmlTagsByAttribute(withoutImageTags, 'a', 'href', mediaPath);
51
+ }
52
+
53
+ function removeMarkdownInlineReferences(source: string, mediaPath: string): string {
54
+ let result = '';
55
+ let cursor = 0;
56
+
57
+ while (cursor < source.length) {
58
+ const range = findNextMarkdownReference(source, cursor, mediaPath);
59
+ if (!range) break;
60
+ result += source.slice(cursor, range.start);
61
+ cursor = range.end;
62
+ }
63
+
64
+ return result + source.slice(cursor);
65
+ }
66
+
67
+ function findNextMarkdownReference(
68
+ source: string,
69
+ startIndex: number,
70
+ mediaPath: string,
71
+ ): MediaReferenceRange | null {
72
+ const match = findNextMarkdownReferenceMatch(source, startIndex, mediaPath);
73
+ return match?.range ?? null;
74
+ }
75
+
76
+ function collectMarkdownInlineReferences(source: string, refs: Set<string>): void {
77
+ let cursor = 0;
78
+ while (cursor < source.length) {
79
+ const match = findNextMarkdownReferenceMatch(source, cursor);
80
+ if (!match) return;
81
+ refs.add(match.destination);
82
+ cursor = Math.max(match.range.end, cursor + 1);
83
+ }
84
+ }
85
+
86
+ function findNextMarkdownReferenceMatch(
87
+ source: string,
88
+ startIndex: number,
89
+ mediaPath?: string,
90
+ ): MarkdownReferenceMatch | null {
91
+ let bracketIndex = source.indexOf('[', startIndex);
92
+
93
+ while (bracketIndex !== -1) {
94
+ const previous = source[bracketIndex - 1];
95
+ const isImage = previous === '!';
96
+ if (previous === '@') {
97
+ bracketIndex = source.indexOf('[', bracketIndex + 1);
98
+ continue;
99
+ }
100
+
101
+ const closeBracket = findClosingBracket(source, bracketIndex);
102
+ if (closeBracket === -1 || source[closeBracket + 1] !== '(') {
103
+ bracketIndex = source.indexOf('[', bracketIndex + 1);
104
+ continue;
105
+ }
106
+
107
+ const openParen = closeBracket + 1;
108
+ const closeParen = findClosingParen(source, openParen);
109
+ if (closeParen === -1) {
110
+ bracketIndex = source.indexOf('[', bracketIndex + 1);
111
+ continue;
112
+ }
113
+
114
+ const destination = readLinkDestination(source.slice(openParen + 1, closeParen));
115
+ if (destination && (mediaPath === undefined || destination === mediaPath)) {
116
+ const tokenStart = isImage ? bracketIndex - 1 : bracketIndex;
117
+ return {
118
+ destination,
119
+ range: expandStandaloneLine(source, tokenStart, closeParen + 1),
120
+ };
121
+ }
122
+
123
+ bracketIndex = source.indexOf('[', bracketIndex + 1);
124
+ }
125
+
126
+ return null;
127
+ }
128
+
129
+ function collectHtmlAttributeReferences(source: string, refs: Set<string>): void {
130
+ const tagPattern = /<[a-z][^>]*>/gi;
131
+ let match: RegExpExecArray | null;
132
+
133
+ while ((match = tagPattern.exec(source))) {
134
+ const tag = match[0];
135
+ for (const attrName of HTML_MEDIA_ATTRS) {
136
+ const value = readHtmlAttribute(tag, attrName);
137
+ if (value) refs.add(value);
138
+ }
139
+ }
140
+ }
141
+
142
+ function collectAnnotationReferences(source: string, refs: Set<string>): void {
143
+ const annotationPattern = /\{\[([^\]]*)\]\}/g;
144
+ let match: RegExpExecArray | null;
145
+
146
+ while ((match = annotationPattern.exec(source))) {
147
+ const inner = match[1];
148
+ if (!inner) continue;
149
+ const tokens = tokenizeAttrTokens(inner);
150
+ for (const token of tokens) {
151
+ const pair = splitKeyValueToken(token);
152
+ if (!pair) continue;
153
+ if (MEDIA_REFERENCE_PARAM_KEYS.has(pair.key.toLowerCase())) {
154
+ refs.add(pair.value);
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ function findClosingBracket(source: string, openIndex: number): number {
161
+ let depth = 0;
162
+ for (let i = openIndex; i < source.length; i++) {
163
+ if (source[i] === '\\') {
164
+ i += 1;
165
+ continue;
166
+ }
167
+ if (source[i] === '[') depth += 1;
168
+ if (source[i] === ']') {
169
+ depth -= 1;
170
+ if (depth === 0) return i;
171
+ }
172
+ }
173
+ return -1;
174
+ }
175
+
176
+ function findClosingParen(source: string, openIndex: number): number {
177
+ let depth = 0;
178
+ let inAngleDestination = false;
179
+
180
+ for (let i = openIndex; i < source.length; i++) {
181
+ const ch = source[i];
182
+ if (ch === '\\') {
183
+ i += 1;
184
+ continue;
185
+ }
186
+ if (inAngleDestination) {
187
+ if (ch === '>') inAngleDestination = false;
188
+ continue;
189
+ }
190
+ if (ch === '<') {
191
+ inAngleDestination = true;
192
+ continue;
193
+ }
194
+ if (ch === '(') {
195
+ depth += 1;
196
+ continue;
197
+ }
198
+ if (ch === ')') {
199
+ depth -= 1;
200
+ if (depth === 0) return i;
201
+ }
202
+ }
203
+
204
+ return -1;
205
+ }
206
+
207
+ function readLinkDestination(raw: string): string {
208
+ const content = raw.trimStart();
209
+ if (!content) return '';
210
+
211
+ if (content.startsWith('<')) {
212
+ const close = findUnescaped(content, '>', 1);
213
+ if (close === -1) return '';
214
+ return unescapeMarkdownUrl(content.slice(1, close));
215
+ }
216
+
217
+ let depth = 0;
218
+ for (let i = 0; i < content.length; i++) {
219
+ const ch = content[i];
220
+ if (ch === '\\') {
221
+ i += 1;
222
+ continue;
223
+ }
224
+ if (ch === '(') {
225
+ depth += 1;
226
+ continue;
227
+ }
228
+ if (ch === ')' && depth > 0) {
229
+ depth -= 1;
230
+ continue;
231
+ }
232
+ if (depth === 0 && /\s/.test(ch)) {
233
+ return unescapeMarkdownUrl(content.slice(0, i));
234
+ }
235
+ }
236
+
237
+ return unescapeMarkdownUrl(content);
238
+ }
239
+
240
+ function findUnescaped(source: string, needle: string, startIndex: number): number {
241
+ for (let i = startIndex; i < source.length; i++) {
242
+ if (source[i] === '\\') {
243
+ i += 1;
244
+ continue;
245
+ }
246
+ if (source[i] === needle) return i;
247
+ }
248
+ return -1;
249
+ }
250
+
251
+ function unescapeMarkdownUrl(value: string): string {
252
+ return value.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1');
253
+ }
254
+
255
+ function expandStandaloneLine(source: string, start: number, end: number): MediaReferenceRange {
256
+ const lineStart = source.lastIndexOf('\n', start - 1) + 1;
257
+ const nextNewline = source.indexOf('\n', end);
258
+ const lineEnd = nextNewline === -1 ? source.length : nextNewline;
259
+ const before = source.slice(lineStart, start);
260
+ const after = source.slice(end, lineEnd);
261
+
262
+ if (before.trim() === '' && after.trim() === '') {
263
+ return {
264
+ start: lineStart,
265
+ end: nextNewline === -1 ? lineEnd : lineEnd + 1,
266
+ };
267
+ }
268
+
269
+ return { start, end };
270
+ }
271
+
272
+ function removeHtmlTagsByAttribute(
273
+ source: string,
274
+ tagName: 'a' | 'img',
275
+ attrName: 'href' | 'src',
276
+ mediaPath: string,
277
+ ): string {
278
+ const tagPattern = tagName === 'img' ? /<img\b[^>]*>/gi : /<a\b[^>]*>[\s\S]*?<\/a>/gi;
279
+ return source.replace(tagPattern, (tag) => {
280
+ const value = readHtmlAttribute(tag, attrName);
281
+ return value === mediaPath ? '' : tag;
282
+ });
283
+ }
284
+
285
+ function readHtmlAttribute(tag: string, attrName: string): string | null {
286
+ const attrPattern = new RegExp(`\\b${attrName}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i');
287
+ const match = attrPattern.exec(tag);
288
+ if (!match) return null;
289
+ return unescapeHtmlAttribute(match[2] ?? match[3] ?? match[4] ?? '');
290
+ }
291
+
292
+ function unescapeHtmlAttribute(value: string): string {
293
+ return value
294
+ .replace(/&lt;/g, '<')
295
+ .replace(/&gt;/g, '>')
296
+ .replace(/&quot;/g, '"')
297
+ .replace(/&#39;|&apos;/g, "'")
298
+ .replace(/&amp;/g, '&');
299
+ }
@@ -389,6 +389,7 @@ export function Scene(props: SceneProps) {
389
389
  // ── Render ──────────────────────────────────────────────────
390
390
  const liveOffset = activeId === 'select' ? getActiveMoveOffset() : null;
391
391
  const liveResize = activeId === 'select' ? getActiveResize() : null;
392
+ const showSelectionHandles = activeTool?.hideSelectionHandles !== true;
392
393
 
393
394
  // Per-layer transform applied during an in-flight drag or resize so
394
395
  // the user sees the layer move/stretch in real time, not just on commit.
@@ -415,6 +416,32 @@ export function Scene(props: SceneProps) {
415
416
  return {};
416
417
  };
417
418
 
419
+ // A text box's font size is independent of its box size — a wide box exists
420
+ // precisely so a small caption can left/center/right-align within it. So
421
+ // when a text box is directly resized we reshape the box and re-align its
422
+ // text at the *unchanged* font, rather than scaling the glyphs with the box
423
+ // (the generic matrix path in `wrapperFor`, which would stretch the text and
424
+ // then snap it back to its real size on commit). Returns a clone positioned
425
+ // at the live-resize box, or null when `layer` isn't a text box being
426
+ // directly resized. Follower labels (drawing/diagram, whose font is derived
427
+ // from box height) are resized via their shape, not directly, so they never
428
+ // match here and keep the scale-with-box preview.
429
+ const liveResizedTextBox = (layer: Layer): Layer | null => {
430
+ if (!liveResize || liveResize.layerId !== layer.id || layer.type !== 'text') return null;
431
+ const b = liveResize.bounds;
432
+ return {
433
+ ...layer,
434
+ position: {
435
+ ...layer.position,
436
+ x: b.x,
437
+ y: b.y,
438
+ width: b.width,
439
+ height: b.height,
440
+ anchor: 'top-left',
441
+ },
442
+ };
443
+ };
444
+
418
445
  const handleHandlePointerDown = (e: React.PointerEvent<SVGElement>, corner: ResizeCorner) => {
419
446
  const id = selection.selection.values().next().value as string | undefined;
420
447
  if (!id) return;
@@ -454,21 +481,40 @@ export function Scene(props: SceneProps) {
454
481
  >
455
482
  {/* Extras (e.g. diagram edges) render behind the layers so the
456
483
  cards visually clip edge endpoints. */}
457
- {renderExtras?.(ctx)}
484
+ {renderExtras && <g className="squisq-scene-extras">{renderExtras(ctx)}</g>}
458
485
  {/* Layers — the Scene wraps each in a transformable <g> so a
459
486
  selected layer can show a live drag/resize preview without
460
487
  requiring the host to know about drag state. */}
461
- {layers.map((layer) => (
462
- <g key={layer.id} data-layer-id={layer.id} {...wrapperFor(layer)}>
463
- {layerRenderer(layer, viewport)}
464
- </g>
465
- ))}
488
+ {layers.map((layer) => {
489
+ const layerClassName = `squisq-scene-layer squisq-scene-layer--${layer.type}`;
490
+ // Text boxes reshape (font-fixed) rather than scale during resize;
491
+ // everything else stretches via the group transform.
492
+ const resizedText = liveResizedTextBox(layer);
493
+ if (resizedText) {
494
+ return (
495
+ <g key={layer.id} className={layerClassName} data-layer-id={layer.id}>
496
+ {layerRenderer(resizedText, viewport)}
497
+ </g>
498
+ );
499
+ }
500
+ return (
501
+ <g
502
+ key={layer.id}
503
+ className={layerClassName}
504
+ data-layer-id={layer.id}
505
+ {...wrapperFor(layer)}
506
+ >
507
+ {layerRenderer(layer, viewport)}
508
+ </g>
509
+ );
510
+ })}
466
511
  <SceneSelection
467
512
  selection={selection.selection}
468
513
  hitItems={hitItems}
469
514
  liveOffset={liveOffset}
470
515
  liveResize={liveResize}
471
- onHandlePointerDown={handleHandlePointerDown}
516
+ onHandlePointerDown={showSelectionHandles ? handleHandlePointerDown : undefined}
517
+ showHandles={showSelectionHandles}
472
518
  />
473
519
  {activeTool?.renderOverlay?.(ctx)}
474
520
  </SceneViewport>
@@ -25,6 +25,7 @@ import { shapeIdFromLayerId } from './layers/shapeLayers';
25
25
  import { ShapePalette } from './ShapePalette';
26
26
  import { ScenePropsBar } from './ScenePropsBar';
27
27
  import { SceneBlockToolbar, type SceneBlockAction, type SceneAlignment } from './SceneBlockToolbar';
28
+ import { SceneSideToolbar } from './SceneSideToolbar';
28
29
  import type { SceneTextEditConfig } from './text/sceneTextConfig';
29
30
  import { markdownToTiptap } from '../tiptapBridge';
30
31
  import { Icon } from '../Icon';
@@ -301,15 +302,15 @@ export function SceneBlockWidget({
301
302
  );
302
303
 
303
304
  const body = <div className="squisq-scene-stage">{canvas}</div>;
304
- const sideToolbar = <div className="squisq-scene-side-toolbar">{toolbar}</div>;
305
305
 
306
306
  if (maximized) {
307
307
  return (
308
308
  <div className="squisq-scene-inline-placeholder">
309
309
  <DiagramMaximizedOverlay host={host ?? null} onClose={() => setMaximized(false)}>
310
+ {/* Maximized has a full screen for a static right column — no collapse. */}
310
311
  <div className="squisq-scene-block-max">
311
312
  {body}
312
- {sideToolbar}
313
+ <div className="squisq-scene-side-toolbar">{toolbar}</div>
313
314
  </div>
314
315
  </DiagramMaximizedOverlay>
315
316
  </div>
@@ -318,8 +319,10 @@ export function SceneBlockWidget({
318
319
 
319
320
  return (
320
321
  <div className="squisq-scene-shell">
322
+ {/* Before the canvas so the narrow-width fallback bar sits above it; the
323
+ wide-width gutter column is absolute and unaffected by DOM order. */}
324
+ <SceneSideToolbar>{toolbar}</SceneSideToolbar>
321
325
  <div className="squisq-scene-inline">{body}</div>
322
- {sideToolbar}
323
326
  </div>
324
327
  );
325
328
  }
@@ -28,6 +28,8 @@ interface SceneSelectionProps {
28
28
  } | null;
29
29
  /** Fired when the user presses a corner/edge handle. */
30
30
  onHandlePointerDown?: (e: React.PointerEvent<SVGElement>, corner: ResizeCorner) => void;
31
+ /** Whether to render resize handles in addition to the selection outline. */
32
+ showHandles?: boolean;
31
33
  }
32
34
 
33
35
  const HANDLE_SIZE = 8;
@@ -38,6 +40,7 @@ export function SceneSelection({
38
40
  liveOffset,
39
41
  liveResize,
40
42
  onHandlePointerDown,
43
+ showHandles = true,
41
44
  }: SceneSelectionProps) {
42
45
  if (selection.size === 0) return null;
43
46
  const selected = hitItems.filter((it) => selection.has(it.id));
@@ -84,21 +87,22 @@ export function SceneSelection({
84
87
  className="squisq-scene-selection-outline"
85
88
  pointerEvents="none"
86
89
  />
87
- {corners.map(({ corner, cx, cy, cursor }) => (
88
- <rect
89
- key={corner}
90
- x={cx - HANDLE_SIZE / 2}
91
- y={cy - HANDLE_SIZE / 2}
92
- width={HANDLE_SIZE}
93
- height={HANDLE_SIZE}
94
- className="squisq-scene-selection-handle"
95
- style={{ cursor }}
96
- onPointerDown={
97
- onHandlePointerDown ? (e) => onHandlePointerDown(e, corner) : undefined
98
- }
99
- data-corner={corner}
100
- />
101
- ))}
90
+ {showHandles &&
91
+ corners.map(({ corner, cx, cy, cursor }) => (
92
+ <rect
93
+ key={corner}
94
+ x={cx - HANDLE_SIZE / 2}
95
+ y={cy - HANDLE_SIZE / 2}
96
+ width={HANDLE_SIZE}
97
+ height={HANDLE_SIZE}
98
+ className="squisq-scene-selection-handle"
99
+ style={{ cursor }}
100
+ onPointerDown={
101
+ onHandlePointerDown ? (e) => onHandlePointerDown(e, corner) : undefined
102
+ }
103
+ data-corner={corner}
104
+ />
105
+ ))}
102
106
  </g>
103
107
  );
104
108
  })}
@@ -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 {