@bendyline/squisq-editor-react 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16910 -6844
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +216 -29
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +61 -31
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +17 -2
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +105 -0
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -0,0 +1,102 @@
1
+ /**
2
+ * SceneViewport — the scrollable, zoomable SVG canvas.
3
+ *
4
+ * Owns the wheel/middle-button pan/zoom event handlers and applies the
5
+ * current `SceneTransform` to a single root `<g>` element. Children
6
+ * render at their natural viewport coordinates; the group transform
7
+ * carries the pan/zoom.
8
+ *
9
+ * Dot-grid background lives behind the layer group as an SVG `<pattern>`
10
+ * so it scales/translates with the canvas exactly the way a "real"
11
+ * canvas grid would.
12
+ */
13
+
14
+ import { forwardRef, type ReactNode, type WheelEvent } from 'react';
15
+ import type { SceneTransform } from './hooks/useScenePanZoom';
16
+
17
+ interface SceneViewportProps {
18
+ width: number;
19
+ height: number;
20
+ transform: SceneTransform;
21
+ /** Called with a wheel event for the viewport. */
22
+ onWheel?: (e: WheelEvent<SVGSVGElement>) => void;
23
+ /** Called for any pointer event over the viewport. */
24
+ onPointerDown?: (e: React.PointerEvent<SVGSVGElement>) => void;
25
+ onPointerMove?: (e: React.PointerEvent<SVGSVGElement>) => void;
26
+ onPointerUp?: (e: React.PointerEvent<SVGSVGElement>) => void;
27
+ onDoubleClick?: (e: React.MouseEvent<SVGSVGElement>) => void;
28
+ /** HTML5 drag-and-drop handlers — used for palette drag-to-place. */
29
+ onDragOver?: (e: React.DragEvent<SVGSVGElement>) => void;
30
+ onDrop?: (e: React.DragEvent<SVGSVGElement>) => void;
31
+ /** Optional CSS cursor (set by the active tool). */
32
+ cursor?: string;
33
+ /** Layers + overlay are children of the transformed group. */
34
+ children: ReactNode;
35
+ }
36
+
37
+ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(function SceneViewport(
38
+ {
39
+ width,
40
+ height,
41
+ transform,
42
+ onWheel,
43
+ onPointerDown,
44
+ onPointerMove,
45
+ onPointerUp,
46
+ onDoubleClick,
47
+ onDragOver,
48
+ onDrop,
49
+ cursor,
50
+ children,
51
+ },
52
+ ref,
53
+ ) {
54
+ const t = transform;
55
+ const groupTransform = `translate(${t.tx} ${t.ty}) scale(${t.scale})`;
56
+
57
+ return (
58
+ <svg
59
+ ref={ref}
60
+ className="squisq-scene-viewport"
61
+ width={width}
62
+ height={height}
63
+ // `data-cursor` drives a CSS rule that picks a custom URL-based
64
+ // cursor (see scene.css). The OS-stock `default` / `crosshair`
65
+ // keywords render as thin or invisible shapes on Windows when
66
+ // users have configured large/white cursor schemes; the URL
67
+ // cursors stay high-contrast on any background.
68
+ data-cursor={cursor ?? 'default'}
69
+ onWheel={onWheel}
70
+ onPointerDown={onPointerDown}
71
+ onPointerMove={onPointerMove}
72
+ onPointerUp={onPointerUp}
73
+ onDoubleClick={onDoubleClick}
74
+ onDragOver={onDragOver}
75
+ onDrop={onDrop}
76
+ role="application"
77
+ // Focusable so the SVG can receive keyboard events directly. The
78
+ // Scene focuses it on pointer-down so Delete/Escape/tool-shortcut
79
+ // keys reach our handlers even when mounted inside a contentEditable
80
+ // ProseMirror surface.
81
+ tabIndex={-1}
82
+ // Disable native browser gestures we don't want (pinch-zoom on the
83
+ // SVG, pan momentum) — pointer events handle these explicitly.
84
+ // CSS sets `touch-action: none` on the viewport class.
85
+ >
86
+ <defs>
87
+ <pattern
88
+ id="squisq-scene-dot-grid"
89
+ x={t.tx}
90
+ y={t.ty}
91
+ width={24 * t.scale}
92
+ height={24 * t.scale}
93
+ patternUnits="userSpaceOnUse"
94
+ >
95
+ <circle cx={1} cy={1} r={1} className="squisq-scene-dot" />
96
+ </pattern>
97
+ </defs>
98
+ <rect width="100%" height="100%" fill="url(#squisq-scene-dot-grid)" />
99
+ <g transform={groupTransform}>{children}</g>
100
+ </svg>
101
+ );
102
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * ShapePalette — a categorized, searchable gallery of drawing shapes.
3
+ *
4
+ * Mirrors `EmojiPicker`'s structure (search + category sections + grid of
5
+ * thumbnails). Picking a shape calls `onPick(kind)`; the host then sets the
6
+ * draw tool's pending kind and activates it. Thumbnails render via the same
7
+ * `shapePath` geometry the canvas uses, so the gallery previews exactly what
8
+ * gets drawn.
9
+ */
10
+
11
+ import { useEffect, useMemo, useRef, useState } from 'react';
12
+ import { shapePath } from '@bendyline/squisq/doc';
13
+
14
+ interface ShapePaletteProps {
15
+ onPick: (kind: string) => void;
16
+ onClose: () => void;
17
+ /**
18
+ * CSS selector for an ancestor that should NOT count as an "outside"
19
+ * click — typically the toolbar button that owns opening/closing the
20
+ * palette. Defaults to the scene block toolbar.
21
+ */
22
+ ignoreOutsideSelector?: string;
23
+ }
24
+
25
+ interface ShapeItem {
26
+ kind: string;
27
+ label: string;
28
+ }
29
+
30
+ const CATALOG: { category: string; items: ShapeItem[] }[] = [
31
+ {
32
+ category: 'Lines',
33
+ items: [
34
+ { kind: 'line', label: 'Line' },
35
+ { kind: 'arrow', label: 'Line Arrow' },
36
+ { kind: 'text', label: 'Text' },
37
+ ],
38
+ },
39
+ {
40
+ category: 'Basic Shapes',
41
+ items: [
42
+ { kind: 'rectangle', label: 'Rectangle' },
43
+ { kind: 'circle', label: 'Ellipse' },
44
+ { kind: 'triangle', label: 'Triangle' },
45
+ { kind: 'right-triangle', label: 'Right Triangle' },
46
+ { kind: 'diamond', label: 'Diamond' },
47
+ { kind: 'pentagon', label: 'Pentagon' },
48
+ { kind: 'hexagon', label: 'Hexagon' },
49
+ { kind: 'octagon', label: 'Octagon' },
50
+ { kind: 'parallelogram', label: 'Parallelogram' },
51
+ { kind: 'trapezoid', label: 'Trapezoid' },
52
+ { kind: 'plus', label: 'Cross' },
53
+ { kind: 'chevron', label: 'Chevron' },
54
+ { kind: 'cylinder', label: 'Cylinder' },
55
+ { kind: 'callout', label: 'Speech Bubble' },
56
+ { kind: 'cloud', label: 'Cloud' },
57
+ { kind: 'heart', label: 'Heart' },
58
+ { kind: 'lightning', label: 'Lightning' },
59
+ ],
60
+ },
61
+ {
62
+ category: 'Stars',
63
+ items: [
64
+ { kind: 'star', label: '5-Point Star' },
65
+ { kind: 'star4', label: '4-Point Star' },
66
+ { kind: 'star6', label: '6-Point Star' },
67
+ ],
68
+ },
69
+ {
70
+ category: 'Block Arrows',
71
+ items: [
72
+ { kind: 'arrow-right', label: 'Arrow Right' },
73
+ { kind: 'arrow-left', label: 'Arrow Left' },
74
+ { kind: 'arrow-up', label: 'Arrow Up' },
75
+ { kind: 'arrow-down', label: 'Arrow Down' },
76
+ { kind: 'double-arrow', label: 'Double Arrow' },
77
+ ],
78
+ },
79
+ ];
80
+
81
+ export function ShapePalette({
82
+ onPick,
83
+ onClose,
84
+ ignoreOutsideSelector = '.squisq-scene-block-toolbar',
85
+ }: ShapePaletteProps) {
86
+ const [query, setQuery] = useState('');
87
+ const ref = useRef<HTMLDivElement>(null);
88
+
89
+ useEffect(() => {
90
+ const onDocPointer = (e: PointerEvent) => {
91
+ // Don't treat a click on the toolbar (where the Shape trigger lives)
92
+ // as "outside" — that button owns opening/closing the palette.
93
+ const target = e.target as Element | null;
94
+ if (ignoreOutsideSelector && target?.closest?.(ignoreOutsideSelector)) return;
95
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
96
+ };
97
+ const onKey = (e: KeyboardEvent) => {
98
+ if (e.key === 'Escape') onClose();
99
+ };
100
+ document.addEventListener('pointerdown', onDocPointer);
101
+ document.addEventListener('keydown', onKey);
102
+ return () => {
103
+ document.removeEventListener('pointerdown', onDocPointer);
104
+ document.removeEventListener('keydown', onKey);
105
+ };
106
+ }, [onClose, ignoreOutsideSelector]);
107
+
108
+ const sections = useMemo(() => {
109
+ const q = query.trim().toLowerCase();
110
+ if (!q) return CATALOG;
111
+ return CATALOG.map((s) => ({
112
+ category: s.category,
113
+ items: s.items.filter((i) => i.label.toLowerCase().includes(q) || i.kind.includes(q)),
114
+ })).filter((s) => s.items.length > 0);
115
+ }, [query]);
116
+
117
+ return (
118
+ <div className="squisq-shape-palette" ref={ref} role="dialog" aria-label="Shapes">
119
+ <input
120
+ className="squisq-shape-palette-search"
121
+ type="text"
122
+ placeholder="Search shapes…"
123
+ value={query}
124
+ autoFocus
125
+ onChange={(e) => setQuery(e.target.value)}
126
+ />
127
+ <div className="squisq-shape-palette-scroll">
128
+ {sections.map((section) => (
129
+ <div key={section.category} className="squisq-shape-palette-section">
130
+ <div className="squisq-shape-palette-heading">{section.category}</div>
131
+ <div className="squisq-shape-palette-grid">
132
+ {section.items.map((item) => (
133
+ <button
134
+ key={item.kind}
135
+ type="button"
136
+ className="squisq-shape-palette-item"
137
+ title={item.label}
138
+ aria-label={item.label}
139
+ onClick={() => onPick(item.kind)}
140
+ >
141
+ <ShapeThumb kind={item.kind} />
142
+ </button>
143
+ ))}
144
+ </div>
145
+ </div>
146
+ ))}
147
+ </div>
148
+ </div>
149
+ );
150
+ }
151
+
152
+ /** A 40×30 SVG thumbnail of a shape, inside a 48×38 button viewport. */
153
+ function ShapeThumb({ kind }: { kind: string }) {
154
+ const d = shapePath(kind, 4, 4, 40, 30);
155
+ return (
156
+ <svg viewBox="0 0 48 38" width={36} height={28} aria-hidden="true">
157
+ {d ? (
158
+ <path d={d} className="squisq-shape-thumb" />
159
+ ) : kind === 'circle' ? (
160
+ <ellipse cx={24} cy={19} rx={20} ry={15} className="squisq-shape-thumb" />
161
+ ) : kind === 'line' ? (
162
+ <line x1={4} y1={34} x2={44} y2={4} className="squisq-shape-thumb-line" />
163
+ ) : kind === 'arrow' ? (
164
+ <line
165
+ x1={4}
166
+ y1={34}
167
+ x2={44}
168
+ y2={4}
169
+ className="squisq-shape-thumb-line"
170
+ markerEnd="url(#squisq-edge-arrow-end)"
171
+ />
172
+ ) : kind === 'text' ? (
173
+ <text x={24} y={26} textAnchor="middle" className="squisq-shape-thumb-text">
174
+ T
175
+ </text>
176
+ ) : (
177
+ <rect x={4} y={6} width={40} height={26} rx={3} className="squisq-shape-thumb" />
178
+ )}
179
+ </svg>
180
+ );
181
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * DiagramAdapter — pure read-side transform. Verifies that given a
3
+ * (nodes, edges) shape from the existing data hook, the adapter
4
+ * produces card+label Layer pairs and matching SceneEdges.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { buildDiagramScene } from '../adapters/DiagramAdapter';
9
+ import { NODE_WIDTH, NODE_HEIGHT } from '../layers/nodeCard';
10
+
11
+ describe('buildDiagramScene', () => {
12
+ it('emits a card + label pair per node and preserves edges', () => {
13
+ const out = buildDiagramScene(
14
+ [
15
+ { id: 'a', position: { x: 100, y: 100 }, data: { label: 'Bar' } },
16
+ { id: 'b', position: { x: 400, y: 100 }, data: { label: 'Baz' } },
17
+ ],
18
+ [{ id: 'a->b', source: 'a', target: 'b', label: 'uses' }],
19
+ );
20
+
21
+ // Two cards + two labels, in that order.
22
+ expect(out.layers.map((l) => l.id)).toEqual([
23
+ 'node-card-a',
24
+ 'node-card-b',
25
+ 'node-label-a',
26
+ 'node-label-b',
27
+ ]);
28
+
29
+ const card = out.layers[0];
30
+ expect(card.type).toBe('shape');
31
+ expect(card.position.x).toBe(100);
32
+ expect(card.position.width).toBe(NODE_WIDTH);
33
+ expect(card.position.height).toBe(NODE_HEIGHT);
34
+
35
+ const label = out.layers[2];
36
+ expect(label.type).toBe('text');
37
+ // Label is centered over its card.
38
+ expect(label.position.x).toBe(100 + NODE_WIDTH / 2);
39
+ expect(label.position.y).toBe(100 + NODE_HEIGHT / 2);
40
+ if (label.type === 'text') expect(label.content.text).toBe('Bar');
41
+
42
+ expect(out.edges).toEqual([{ id: 'a->b', source: 'a', target: 'b', label: 'uses' }]);
43
+ expect(out.nodes).toEqual([
44
+ { id: 'a', label: 'Bar', x: 100, y: 100 },
45
+ { id: 'b', label: 'Baz', x: 400, y: 100 },
46
+ ]);
47
+ });
48
+
49
+ it('omits edge label when none provided', () => {
50
+ const out = buildDiagramScene(
51
+ [{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'A' } }],
52
+ [{ id: 'a->b', source: 'a', target: 'b' }],
53
+ );
54
+ expect(out.edges[0]).toEqual({ id: 'a->b', source: 'a', target: 'b' });
55
+ });
56
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * bezierEdit — parse/serialize round-trip and editing operations.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { parsePath, serializePath, moveAnchor, moveHandle } from '../paths/bezierEdit';
7
+
8
+ describe('parsePath / serializePath', () => {
9
+ it('parses an M then L sequence', () => {
10
+ const p = parsePath('M 10 20 L 30 40');
11
+ expect(p.points).toEqual([
12
+ { kind: 'move', x: 10, y: 20 },
13
+ { kind: 'line', x: 30, y: 40 },
14
+ ]);
15
+ expect(p.closed).toBe(false);
16
+ });
17
+
18
+ it('treats implicit pairs after M as L commands', () => {
19
+ const p = parsePath('M 0 0 10 10 20 20');
20
+ expect(p.points.map((q) => q.kind)).toEqual(['move', 'line', 'line']);
21
+ expect(p.points[2]).toMatchObject({ x: 20, y: 20 });
22
+ });
23
+
24
+ it('parses a cubic curve and attaches handles to the surrounding anchors', () => {
25
+ const p = parsePath('M 0 0 C 10 0 90 100 100 100');
26
+ expect(p.points).toHaveLength(2);
27
+ const m = p.points[0];
28
+ const c = p.points[1];
29
+ expect(m.cpOut).toEqual({ x: 10, y: 0 });
30
+ expect(c.cpIn).toEqual({ x: 90, y: 100 });
31
+ expect(c).toMatchObject({ kind: 'curve', x: 100, y: 100 });
32
+ });
33
+
34
+ it('captures Z as closed', () => {
35
+ const p = parsePath('M 0 0 L 10 0 L 10 10 Z');
36
+ expect(p.closed).toBe(true);
37
+ });
38
+
39
+ it('round-trips M/L/C/Z paths via serialize ∘ parse', () => {
40
+ const d = 'M 0 0 C 20 0 80 100 100 100 L 100 200 Z';
41
+ const round = serializePath(parsePath(d));
42
+ expect(round).toBe(d);
43
+ });
44
+
45
+ it('skips unknown commands without throwing', () => {
46
+ const p = parsePath('M 0 0 A 50 50 0 1 0 100 0 L 100 100');
47
+ // The A is dropped (unsupported), but the subsequent L survives — though
48
+ // its anchor sits where the parser was when A's args were skipped.
49
+ // Verify we got the M and at least one L without crashing.
50
+ expect(p.points[0]).toMatchObject({ kind: 'move' });
51
+ expect(p.points.some((q) => q.kind === 'line')).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe('moveAnchor', () => {
56
+ it('shifts the anchor and its handles by the same delta', () => {
57
+ const path = parsePath('M 0 0 C 10 0 90 100 100 100');
58
+ const moved = moveAnchor(path, 1, 5, 5);
59
+ const c = moved.points[1];
60
+ expect(c).toMatchObject({ x: 105, y: 105 });
61
+ // Incoming handle moves with the anchor.
62
+ expect(c.cpIn).toEqual({ x: 95, y: 105 });
63
+ });
64
+
65
+ it('does not mutate the input', () => {
66
+ const path = parsePath('M 0 0 L 10 10');
67
+ const before = JSON.stringify(path);
68
+ moveAnchor(path, 1, 100, 100);
69
+ expect(JSON.stringify(path)).toBe(before);
70
+ });
71
+
72
+ it('is a no-op for an out-of-range index', () => {
73
+ const path = parsePath('M 0 0 L 10 10');
74
+ const result = moveAnchor(path, 99, 5, 5);
75
+ expect(result).toEqual(path);
76
+ });
77
+ });
78
+
79
+ describe('moveHandle', () => {
80
+ it('replaces a single control-point handle', () => {
81
+ const path = parsePath('M 0 0 C 10 0 90 100 100 100');
82
+ const out = moveHandle(path, 1, 'cpIn', 200, 200);
83
+ expect(out.points[1].cpIn).toEqual({ x: 200, y: 200 });
84
+ });
85
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * blockLayers — base64-JSON encode/decode round-trip for Layer arrays.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { encodeLayers, decodeLayers } from '../adapters/blockLayers';
7
+ import type { Layer, ShapeLayer, TextLayer } from '@bendyline/squisq/schemas';
8
+
9
+ describe('encodeLayers / decodeLayers', () => {
10
+ it('round-trips a single ShapeLayer', () => {
11
+ const layer: ShapeLayer = {
12
+ id: 'rect-1',
13
+ type: 'shape',
14
+ position: { x: 100, y: 200, width: 50, height: 30 },
15
+ content: { shape: 'rect', fill: '#abc', stroke: '#000', strokeWidth: 2 },
16
+ };
17
+ expect(decodeLayers(encodeLayers([layer]))).toEqual([layer]);
18
+ });
19
+
20
+ it('round-trips a mixed-type array preserving order', () => {
21
+ const layers: Layer[] = [
22
+ {
23
+ id: 'a',
24
+ type: 'shape',
25
+ position: { x: 0, y: 0, width: 10, height: 10 },
26
+ content: { shape: 'circle', fill: 'red' },
27
+ },
28
+ {
29
+ id: 'b',
30
+ type: 'text',
31
+ position: { x: 20, y: 20, width: 80, height: 24 },
32
+ content: { text: 'Hi there', style: { fontSize: 18, color: '#111' } },
33
+ } as TextLayer,
34
+ ];
35
+ expect(decodeLayers(encodeLayers(layers))).toEqual(layers);
36
+ });
37
+
38
+ it('returns [] for invalid base64 / JSON', () => {
39
+ expect(decodeLayers('not-valid-base64!!')).toEqual([]);
40
+ expect(decodeLayers(Buffer.from('not json', 'utf-8').toString('base64'))).toEqual([]);
41
+ });
42
+
43
+ it('returns [] when the decoded value is not an array', () => {
44
+ const encoded = Buffer.from(JSON.stringify({ not: 'an array' }), 'utf-8').toString('base64');
45
+ expect(decodeLayers(encoded)).toEqual([]);
46
+ });
47
+
48
+ it('encodes deterministically (same input → same output)', () => {
49
+ const layer: ShapeLayer = {
50
+ id: 'x',
51
+ type: 'shape',
52
+ position: { x: 0, y: 0, width: 5, height: 5 },
53
+ content: { shape: 'rect' },
54
+ };
55
+ expect(encodeLayers([layer])).toBe(encodeLayers([layer]));
56
+ });
57
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * shapeLayers — pure read transform. Given the shapes `computeDrawingLayout`
3
+ * derives from a drawing's children, verifies the Scene Layer(s), boxes, and
4
+ * id helpers the DrawingAdapter relies on.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { computeDrawingLayout } from '@bendyline/squisq/doc';
9
+ import type { Block } from '@bendyline/squisq/schemas';
10
+ import {
11
+ shapesToSceneLayers,
12
+ shapeBoxes,
13
+ drawingLayerFollows,
14
+ shapeIdFromLayerId,
15
+ isPrimaryShapeLayer,
16
+ primaryLayerId,
17
+ } from '../layers/shapeLayers';
18
+
19
+ function shapeBlock(
20
+ id: string,
21
+ template: string,
22
+ overrides: Record<string, string>,
23
+ title?: string,
24
+ ): Block {
25
+ return {
26
+ id,
27
+ startTime: 0,
28
+ duration: 0,
29
+ audioSegment: 0,
30
+ template,
31
+ templateOverrides: overrides,
32
+ ...(title ? { title } : {}),
33
+ } as Block;
34
+ }
35
+
36
+ describe('shapeLayers read transform', () => {
37
+ it('emits a shape + follower label per rect, with dshape-/dlabel- ids', () => {
38
+ const layout = computeDrawingLayout([
39
+ shapeBlock('ceo', 'rectangle', { x: '20', y: '20', width: '120', height: '80' }, 'CEO'),
40
+ ]);
41
+ const layers = shapesToSceneLayers(layout.shapes);
42
+ expect(layers.map((l) => l.id)).toEqual(['dshape-ceo', 'dlabel-ceo']);
43
+ const [shape, label] = layers;
44
+ expect(shape.type).toBe('shape');
45
+ expect(shape.position).toMatchObject({ x: 20, y: 20, width: 120, height: 80 });
46
+ expect(label.type).toBe('text');
47
+ if (label.type === 'text') expect(label.content.text).toBe('CEO');
48
+ });
49
+
50
+ it('maps each shape kind to the right Layer type', () => {
51
+ const layout = computeDrawingLayout([
52
+ shapeBlock('r', 'rectangle', { x: '0', y: '0', width: '10', height: '10' }),
53
+ shapeBlock('c', 'circle', { x: '0', y: '0', width: '10', height: '10' }),
54
+ shapeBlock('l', 'line', { x: '0', y: '0', width: '10', height: '10' }),
55
+ shapeBlock('p', 'path', { x: '0', y: '0', d: 'M 0 0 L 5 5' }),
56
+ shapeBlock('t', 'text', { x: '0', y: '0', text: 'hi' }),
57
+ ]);
58
+ const byId = new Map(shapesToSceneLayers(layout.shapes).map((l) => [l.id, l]));
59
+ expect(byId.get('dshape-r')?.type).toBe('shape');
60
+ expect(byId.get('dshape-c')?.type).toBe('shape');
61
+ expect(byId.get('dshape-l')?.type).toBe('shape'); // plain line → bbox shape
62
+ expect(byId.get('dshape-p')?.type).toBe('path');
63
+ expect(byId.get('dshape-t')?.type).toBe('text');
64
+ });
65
+
66
+ it('does not emit a label layer for a text shape', () => {
67
+ const layout = computeDrawingLayout([
68
+ shapeBlock('t', 'text', { x: '0', y: '0', text: 'Note' }),
69
+ ]);
70
+ expect(shapesToSceneLayers(layout.shapes).map((l) => l.id)).toEqual(['dshape-t']);
71
+ });
72
+
73
+ it('exposes {id,x,y,width,height} boxes for the connector renderer', () => {
74
+ const layout = computeDrawingLayout([
75
+ shapeBlock('a', 'rect', { x: '5', y: '6', width: '7', height: '8' }),
76
+ ]);
77
+ expect(shapeBoxes(layout.shapes)).toEqual([{ id: 'a', x: 5, y: 6, width: 7, height: 8 }]);
78
+ });
79
+
80
+ it('links a label to its shape and recovers shape ids from layer ids', () => {
81
+ expect(drawingLayerFollows('dlabel-ceo')).toBe('dshape-ceo');
82
+ expect(drawingLayerFollows('dshape-ceo')).toBeNull();
83
+ expect(shapeIdFromLayerId('dshape-ceo')).toBe('ceo');
84
+ expect(shapeIdFromLayerId('dlabel-ceo')).toBe('ceo');
85
+ expect(shapeIdFromLayerId('whatever')).toBeNull();
86
+ expect(isPrimaryShapeLayer('dshape-ceo')).toBe(true);
87
+ expect(isPrimaryShapeLayer('dlabel-ceo')).toBe(false);
88
+ expect(primaryLayerId('ceo')).toBe('dshape-ceo');
89
+ });
90
+
91
+ it('connectors (line/arrow with from/to) are edges, not shapes', () => {
92
+ const layout = computeDrawingLayout([
93
+ shapeBlock('a', 'rect', { x: '0', y: '0', width: '50', height: '50' }),
94
+ shapeBlock('b', 'rect', { x: '0', y: '200', width: '50', height: '50' }),
95
+ shapeBlock('e', 'arrow', { from: 'a', to: 'b' }, 'reports to'),
96
+ ]);
97
+ expect(shapesToSceneLayers(layout.shapes).map((l) => l.id)).toEqual(['dshape-a', 'dshape-b']);
98
+ expect(layout.connectors).toHaveLength(1);
99
+ expect(layout.connectors[0]).toMatchObject({
100
+ id: 'e',
101
+ from: 'a',
102
+ to: 'b',
103
+ label: 'reports to',
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * useSceneHitTest + layerBounds — coordinate resolution and topmost-wins
3
+ * hit testing. Pure logic; no DOM needed.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { renderHook } from '@testing-library/react';
8
+ import { useSceneHitTest, layerBounds, type HitTestable } from '../hooks/useSceneHitTest';
9
+ import type { ShapeLayer } from '@bendyline/squisq/schemas';
10
+
11
+ const viewport = { width: 1000, height: 800 };
12
+
13
+ function rectLayer(id: string, x: number, y: number, w: number, h: number): ShapeLayer {
14
+ return {
15
+ id,
16
+ type: 'shape',
17
+ position: { x, y, width: w, height: h },
18
+ content: { shape: 'rect' },
19
+ };
20
+ }
21
+
22
+ describe('layerBounds', () => {
23
+ it('resolves numeric pixel positions', () => {
24
+ const b = layerBounds(rectLayer('a', 10, 20, 100, 50), viewport);
25
+ expect(b).toEqual({ x: 10, y: 20, width: 100, height: 50 });
26
+ });
27
+
28
+ it('resolves percentage strings against the viewport', () => {
29
+ const l: ShapeLayer = {
30
+ id: 'p',
31
+ type: 'shape',
32
+ position: { x: '10%', y: '25%', width: '50%', height: '20%' },
33
+ content: { shape: 'rect' },
34
+ };
35
+ const b = layerBounds(l, viewport);
36
+ expect(b).toEqual({ x: 100, y: 200, width: 500, height: 160 });
37
+ });
38
+
39
+ it('applies center anchor by shifting the origin to the bounding-box top-left', () => {
40
+ const l: ShapeLayer = {
41
+ id: 'c',
42
+ type: 'shape',
43
+ position: { x: 500, y: 400, width: 200, height: 100, anchor: 'center' },
44
+ content: { shape: 'rect' },
45
+ };
46
+ const b = layerBounds(l, viewport);
47
+ expect(b).toEqual({ x: 400, y: 350, width: 200, height: 100 });
48
+ });
49
+
50
+ it('returns null when width or height cannot be resolved', () => {
51
+ const l: ShapeLayer = {
52
+ id: 'n',
53
+ type: 'shape',
54
+ position: { x: 0, y: 0 },
55
+ content: { shape: 'rect' },
56
+ };
57
+ expect(layerBounds(l, viewport)).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe('useSceneHitTest', () => {
62
+ function items(...layers: ShapeLayer[]): HitTestable[] {
63
+ return layers.map((l) => ({
64
+ id: l.id,
65
+ layer: l,
66
+ bounds: layerBounds(l, viewport)!,
67
+ }));
68
+ }
69
+
70
+ it('returns the topmost layer at a point (last-wins)', () => {
71
+ const { result } = renderHook(() => useSceneHitTest());
72
+ const list = items(rectLayer('a', 0, 0, 100, 100), rectLayer('b', 50, 50, 100, 100));
73
+ // Point (60, 60) is inside both — `b` is later in the array, so wins.
74
+ expect(result.current.hit({ x: 60, y: 60 }, list)).toBe('b');
75
+ });
76
+
77
+ it('returns null when the point is outside every layer', () => {
78
+ const { result } = renderHook(() => useSceneHitTest());
79
+ const list = items(rectLayer('a', 0, 0, 50, 50));
80
+ expect(result.current.hit({ x: 500, y: 500 }, list)).toBeNull();
81
+ });
82
+
83
+ it('treats the bounding-box edge as inside (>= and <=)', () => {
84
+ const { result } = renderHook(() => useSceneHitTest());
85
+ const list = items(rectLayer('a', 10, 10, 100, 100));
86
+ expect(result.current.hit({ x: 10, y: 10 }, list)).toBe('a');
87
+ expect(result.current.hit({ x: 110, y: 110 }, list)).toBe('a');
88
+ expect(result.current.hit({ x: 9.9, y: 10 }, list)).toBeNull();
89
+ });
90
+ });