@bendyline/squisq-editor-react 1.5.3 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -10
- package/dist/index.d.ts +1114 -52
- package/dist/index.js +18446 -8050
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +13867 -0
- package/package.json +15 -7
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +165 -16
- package/src/EditorShell.tsx +265 -144
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +64 -7
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +491 -122
- package/src/PreviewPanel.tsx +15 -9
- package/src/RawEditor.tsx +179 -17
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +544 -180
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +4 -16
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +96 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/previewControls.test.tsx +70 -0
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +41 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +109 -1
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -11
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/code-context.css +155 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1708 -150
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +5 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +23 -1
- package/src/useTimelineClock.ts +76 -0
- 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
|
+
});
|