@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,161 @@
1
+ /**
2
+ * SceneTextOverlay — the shared inline rich-text editor for canvas
3
+ * textboxes. An absolutely-positioned HTML Tiptap instance laid over the
4
+ * edited layer's on-screen box (so it tracks pan/zoom), rendered inside
5
+ * `.squisq-scene-root`. Real HTML over the SVG (not a transformed
6
+ * foreignObject) to avoid SVG focus/IME quirks.
7
+ *
8
+ * Lifecycle: focuses on mount, publishes its editor to `sceneTextChannel`
9
+ * (so the top toolbar can format it), commits on blur/Enter(inline)/
10
+ * Cmd+Enter and cancels on Escape. Clears the channel on unmount.
11
+ */
12
+
13
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
14
+ import { useEditor, EditorContent } from '@tiptap/react';
15
+ import type { Layer } from '@bendyline/squisq/schemas';
16
+ import { DEFAULT_DOC_FONT } from '@bendyline/squisq/schemas';
17
+ import { plainTextFromInlineHtml } from '@bendyline/squisq/markdown';
18
+ import { layerBounds } from '../hooks/useSceneHitTest';
19
+ import type { SceneTransform } from '../hooks/useScenePanZoom';
20
+ import type { SceneTextEditConfig } from './sceneTextConfig';
21
+ import { buildSceneTextExtensions } from './sceneTiptap';
22
+ import { sceneTextChannel } from './sceneTextChannel';
23
+
24
+ interface SceneTextOverlayProps {
25
+ config: SceneTextEditConfig;
26
+ /** The layer being edited (for box geometry + base text style). */
27
+ layer: Layer;
28
+ editableId: string;
29
+ viewport: { width: number; height: number };
30
+ transform: SceneTransform;
31
+ viewportToScreen: (vx: number, vy: number) => { x: number; y: number };
32
+ onClose: () => void;
33
+ }
34
+
35
+ export function SceneTextOverlay({
36
+ config,
37
+ layer,
38
+ editableId,
39
+ viewport,
40
+ transform,
41
+ viewportToScreen,
42
+ onClose,
43
+ }: SceneTextOverlayProps) {
44
+ const initialHtml = useMemo(() => config.getHtml(editableId), [config, editableId]);
45
+ const style = layer.type === 'text' ? layer.content.style : undefined;
46
+
47
+ // Stable commit/cancel via refs so the editor's keydown closure (bound
48
+ // once at creation) always calls the latest handlers.
49
+ const committedRef = useRef(false);
50
+ const commitRef = useRef<() => void>(() => {});
51
+ const cancelRef = useRef<() => void>(() => {});
52
+
53
+ const editor = useEditor({
54
+ extensions: buildSceneTextExtensions(config.level),
55
+ content: initialHtml,
56
+ autofocus: 'end',
57
+ editorProps: {
58
+ handleKeyDown: (_view, event) => {
59
+ if (event.key === 'Escape') {
60
+ event.preventDefault();
61
+ cancelRef.current();
62
+ return true;
63
+ }
64
+ // Inline labels: Enter commits (Shift+Enter = hard break). Rich
65
+ // textboxes: Enter is a paragraph; commit on Cmd/Ctrl+Enter.
66
+ const enterCommits =
67
+ config.level === 'inline' ? !event.shiftKey : event.metaKey || event.ctrlKey;
68
+ if (event.key === 'Enter' && enterCommits) {
69
+ event.preventDefault();
70
+ commitRef.current();
71
+ return true;
72
+ }
73
+ return false;
74
+ },
75
+ },
76
+ });
77
+
78
+ commitRef.current = useCallback(() => {
79
+ if (committedRef.current || !editor) return;
80
+ committedRef.current = true;
81
+ const html = editor.getHTML();
82
+ config.commit(editableId, { html, text: plainTextFromInlineHtml(html) });
83
+ onClose();
84
+ }, [editor, config, editableId, onClose]) as () => void;
85
+
86
+ cancelRef.current = useCallback(() => {
87
+ committedRef.current = true; // suppress the blur-commit that follows
88
+ onClose();
89
+ }, [onClose]) as () => void;
90
+
91
+ // Publish to the toolbar channel while mounted; clear on unmount.
92
+ useEffect(() => {
93
+ if (!editor) return;
94
+ sceneTextChannel.set({ editor, level: config.level });
95
+ return () => {
96
+ if (sceneTextChannel.get()?.editor === editor) sceneTextChannel.set(null);
97
+ };
98
+ }, [editor, config.level]);
99
+
100
+ // Commit when focus leaves the editor — unless it went to the toolbar
101
+ // (the user clicking Bold) or stayed within the overlay.
102
+ const onBlur = useCallback((e: React.FocusEvent) => {
103
+ const next = e.relatedTarget as HTMLElement | null;
104
+ if (next && next.closest('.squisq-toolbar, .squisq-scene-text-overlay')) return;
105
+ commitRef.current();
106
+ }, []);
107
+
108
+ const bounds = layerBounds(layer, viewport);
109
+ if (!bounds) return null;
110
+ const tl = viewportToScreen(bounds.x, bounds.y);
111
+ const scale = transform.scale;
112
+ const fontSize = (style?.fontSize ?? 18) * scale;
113
+ const pad = (style?.padding ?? 0) * scale;
114
+ // Mirror the layer's vertical alignment so changing it (via the toolbar)
115
+ // updates the editor live, matching what the committed box will look like.
116
+ const justifyContent =
117
+ style?.verticalAlign === 'middle'
118
+ ? 'center'
119
+ : style?.verticalAlign === 'bottom'
120
+ ? 'flex-end'
121
+ : 'flex-start';
122
+
123
+ return (
124
+ <div
125
+ className="squisq-scene-text-overlay"
126
+ onBlur={onBlur}
127
+ // Keep clicks inside the editor from reaching the SVG (which would
128
+ // focus the canvas and blur us mid-edit).
129
+ onPointerDown={(e) => e.stopPropagation()}
130
+ onMouseDown={(e) => e.stopPropagation()}
131
+ onDoubleClick={(e) => e.stopPropagation()}
132
+ style={{
133
+ position: 'absolute',
134
+ left: tl.x,
135
+ top: tl.y,
136
+ width: bounds.width * scale,
137
+ height: bounds.height * scale,
138
+ zIndex: 20,
139
+ boxSizing: 'border-box',
140
+ display: 'flex',
141
+ flexDirection: 'column',
142
+ justifyContent,
143
+ padding: pad,
144
+ fontSize,
145
+ fontFamily: style?.fontFamily || DEFAULT_DOC_FONT,
146
+ color: style?.color ?? '#1e293b',
147
+ lineHeight: style?.lineHeight || 1.4,
148
+ textAlign: style?.textAlign ?? 'left',
149
+ background: '#ffffff',
150
+ border: '2px solid #6366f1',
151
+ borderRadius: 4,
152
+ overflow: 'auto',
153
+ cursor: 'text',
154
+ }}
155
+ >
156
+ <EditorContent editor={editor} className="squisq-scene-text-editor" />
157
+ </div>
158
+ );
159
+ }
160
+
161
+ export default SceneTextOverlay;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * sceneTextChannel — a module singleton that bridges the canvas's inline
3
+ * text editor (which renders in a **detached React root** created by the
4
+ * Diagram/SceneBlock ProseMirror extensions, outside `<EditorProvider>`)
5
+ * to the provider, so the top formatting toolbar can target it.
6
+ *
7
+ * The active textbox's `SceneTextOverlay` publishes its Tiptap editor here
8
+ * on focus and clears it on blur/unmount; `EditorProvider` subscribes and
9
+ * mirrors the handle into `activeSceneText`. Singleton because only one
10
+ * canvas textbox can be focused at a time.
11
+ */
12
+
13
+ import type { Editor as TiptapEditor } from '@tiptap/core';
14
+ import type { SceneTextLevel } from './sceneTextConfig';
15
+
16
+ export interface SceneTextHandle {
17
+ editor: TiptapEditor;
18
+ level: SceneTextLevel;
19
+ }
20
+
21
+ type Listener = (handle: SceneTextHandle | null) => void;
22
+
23
+ let current: SceneTextHandle | null = null;
24
+ const listeners = new Set<Listener>();
25
+
26
+ export const sceneTextChannel = {
27
+ /** Publish (or clear with `null`) the focused canvas textbox editor. */
28
+ set(handle: SceneTextHandle | null): void {
29
+ current = handle;
30
+ for (const listener of listeners) listener(handle);
31
+ },
32
+ get(): SceneTextHandle | null {
33
+ return current;
34
+ },
35
+ /** Subscribe to handle changes. Returns an unsubscribe function. */
36
+ subscribe(listener: Listener): () => void {
37
+ listeners.add(listener);
38
+ return () => listeners.delete(listener);
39
+ },
40
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Configuration a Scene host (diagram / drawing / layout widget) supplies
3
+ * so the shared inline text editor knows how to read and write the text of
4
+ * a given layer. The Scene owns the editing UI (overlay + positioning);
5
+ * the host owns persistence (markdown heading vs. layout JSON blob).
6
+ */
7
+
8
+ export type SceneTextLevel = 'inline' | 'block' | 'rich';
9
+
10
+ export interface SceneTextEditConfig {
11
+ /**
12
+ * Map a hit layer id (what the pointer landed on) to the layer id whose
13
+ * on-screen box the editor should cover, or `null` when that layer isn't
14
+ * text-editable. e.g. a diagram card hit (`node-card-x`) maps to itself;
15
+ * a non-text drawing shape maps to `null`.
16
+ */
17
+ resolveEditableId: (layerId: string) => string | null;
18
+ /** Seed HTML for the editor (e.g. `content.html` or `markdownToTiptap(label)`). */
19
+ getHtml: (editableId: string) => string;
20
+ /** Persist the edited content. The host serializes per its storage model. */
21
+ commit: (editableId: string, content: { html: string; text: string }) => void;
22
+ /**
23
+ * Toolbar capability gate: `inline` = marks only; `block` = marks + lists +
24
+ * blockquote (no headings, for layout textboxes); `rich` = also headings.
25
+ */
26
+ level: SceneTextLevel;
27
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Slim Tiptap presets for the canvas inline text editor.
3
+ *
4
+ * Deliberately NOT the document editor's extension set: a textbox must not
5
+ * recursively mount canvases (no `DiagramExtension`/`SceneBlockExtension`)
6
+ * or carry block-template annotations (no `HeadingWithTemplate`).
7
+ *
8
+ * - `inline` → paragraph + marks (bold/italic/strike/code) + link. Used for
9
+ * single-label text (diagram nodes, drawing shapes).
10
+ * - `block` → also lists, blockquote, code block — but NO headings. Used for
11
+ * layout textboxes, whose content is stored as the child sub-block's
12
+ * markdown body (a heading there would re-parse as a sibling sub-block).
13
+ * - `rich` → also headings. (Currently unused by shipping surfaces.)
14
+ */
15
+
16
+ import type { Extensions } from '@tiptap/core';
17
+ import StarterKit from '@tiptap/starter-kit';
18
+ import Link from '@tiptap/extension-link';
19
+ import type { SceneTextLevel } from './sceneTextConfig';
20
+
21
+ export function buildSceneTextExtensions(level: SceneTextLevel): Extensions {
22
+ const starter =
23
+ level === 'inline'
24
+ ? StarterKit.configure({
25
+ heading: false,
26
+ bulletList: false,
27
+ orderedList: false,
28
+ blockquote: false,
29
+ codeBlock: false,
30
+ horizontalRule: false,
31
+ })
32
+ : level === 'block'
33
+ ? StarterKit.configure({ heading: false, horizontalRule: false })
34
+ : StarterKit.configure({ horizontalRule: false });
35
+ return [starter, Link.configure({ openOnClick: false })];
36
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * useSceneTextEditing — Scene-level state for "which layer is being text
3
+ * edited". The Scene wires `begin` into the tool context (`beginTextEdit`)
4
+ * so SelectTool's double-click can open the editor, and renders
5
+ * `<SceneTextOverlay>` while `editingId` is set.
6
+ */
7
+
8
+ import { useCallback, useRef, useState } from 'react';
9
+ import type { SceneTextEditConfig } from './sceneTextConfig';
10
+
11
+ export interface SceneTextEditing {
12
+ /** The editable layer id currently being edited, or null. */
13
+ editingId: string | null;
14
+ /** Open the editor for the layer the pointer hit (resolved via config). */
15
+ begin: (hitLayerId: string) => void;
16
+ /** Close the editor (after commit/cancel). */
17
+ close: () => void;
18
+ /** Ref mirror of whether an edit is active — for non-React event handlers. */
19
+ activeRef: React.MutableRefObject<boolean>;
20
+ }
21
+
22
+ export function useSceneTextEditing(config?: SceneTextEditConfig): SceneTextEditing {
23
+ const [editingId, setEditingId] = useState<string | null>(null);
24
+ const activeRef = useRef(false);
25
+ activeRef.current = editingId != null;
26
+
27
+ const begin = useCallback(
28
+ (hitLayerId: string) => {
29
+ if (!config) return;
30
+ const id = config.resolveEditableId(hitLayerId);
31
+ if (id) setEditingId(id);
32
+ },
33
+ [config],
34
+ );
35
+
36
+ const close = useCallback(() => setEditingId(null), []);
37
+
38
+ return { editingId, begin, close, activeRef };
39
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * ConnectTool — drag from a diagram node to another to create an edge.
3
+ *
4
+ * Press on a node-card layer to anchor the connection start. While
5
+ * dragging, render a dashed preview path from the source node's center
6
+ * to the pointer. On release over a different node-card layer, dispatch
7
+ * an `addEdge` command. Release elsewhere cancels.
8
+ *
9
+ * The tool reads the node descriptors from `ctx.edges`/`ctx.layers` —
10
+ * not from a parallel data store — so it stays in sync with whatever
11
+ * the DiagramAdapter just produced.
12
+ */
13
+
14
+ import { createElement, type JSX } from 'react';
15
+ import type { SceneTool, SceneToolContext } from './SceneTool';
16
+ import { nodeIdFromCardLayerId, NODE_WIDTH, NODE_HEIGHT } from '../layers/nodeCard';
17
+
18
+ interface ConnectState {
19
+ sourceNodeId: string;
20
+ /** Source node center in viewport units (cached at drag start). */
21
+ sourceCenter: { x: number; y: number };
22
+ /** Current pointer position in viewport units. */
23
+ current: { x: number; y: number };
24
+ /** Currently-hovered target node id, if any (for highlight + drop). */
25
+ hoveredTargetId: string | null;
26
+ }
27
+
28
+ let state: ConnectState | null = null;
29
+
30
+ function pointerFromEvent(e: React.PointerEvent): { sx: number; sy: number } {
31
+ const rect = (e.currentTarget as Element).getBoundingClientRect();
32
+ return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
33
+ }
34
+
35
+ function nodeCenterFromCard(
36
+ ctx: SceneToolContext,
37
+ nodeId: string,
38
+ ): { x: number; y: number } | null {
39
+ const card = ctx.hitItems.find((it) => it.id === `node-card-${nodeId}`);
40
+ if (!card) return null;
41
+ return { x: card.bounds.x + NODE_WIDTH / 2, y: card.bounds.y + NODE_HEIGHT / 2 };
42
+ }
43
+
44
+ export const ConnectTool: SceneTool = {
45
+ id: 'connect',
46
+ label: 'Connect',
47
+ cursor: 'crosshair',
48
+ shortcut: 'c',
49
+
50
+ onPointerDown(e, ctx) {
51
+ if (e.button !== 0) return;
52
+ const { sx, sy } = pointerFromEvent(e);
53
+ const v = ctx.screenToViewport(sx, sy);
54
+ const hitId = ctx.hit(v);
55
+ if (!hitId) return;
56
+ const nodeId = nodeIdFromCardLayerId(hitId);
57
+ if (!nodeId) return;
58
+ const center = nodeCenterFromCard(ctx, nodeId);
59
+ if (!center) return;
60
+ state = {
61
+ sourceNodeId: nodeId,
62
+ sourceCenter: center,
63
+ current: v,
64
+ hoveredTargetId: null,
65
+ };
66
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
67
+ e.stopPropagation();
68
+ },
69
+
70
+ onPointerMove(e, ctx) {
71
+ if (!state) return;
72
+ const { sx, sy } = pointerFromEvent(e);
73
+ const v = ctx.screenToViewport(sx, sy);
74
+ state.current = v;
75
+ const hitId = ctx.hit(v);
76
+ const nodeId = hitId ? nodeIdFromCardLayerId(hitId) : null;
77
+ state.hoveredTargetId = nodeId && nodeId !== state.sourceNodeId ? nodeId : null;
78
+ },
79
+
80
+ onPointerUp(e, ctx) {
81
+ if (!state) return;
82
+ const target = state.hoveredTargetId;
83
+ if (target) {
84
+ ctx.dispatch({ kind: 'addEdge', source: state.sourceNodeId, target });
85
+ }
86
+ (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
87
+ state = null;
88
+ },
89
+
90
+ renderOverlay(): JSX.Element | null {
91
+ if (!state) return null;
92
+ const { sourceCenter: a, current: b } = state;
93
+ const dx = b.x - a.x;
94
+ const cp = Math.max(40, Math.abs(dx) / 2);
95
+ // Cubic bezier matching diagramBlock.ts's `curved` edge style so the
96
+ // preview previews exactly what will render after commit.
97
+ const d = `M ${a.x} ${a.y} C ${a.x + cp} ${a.y}, ${b.x - cp} ${b.y}, ${b.x} ${b.y}`;
98
+ return createElement(
99
+ 'g',
100
+ { key: 'connect-preview' },
101
+ createElement('path', { d, className: 'squisq-scene-connect-preview' }),
102
+ // Optional: a dot at the endpoint so the user sees the snap target.
103
+ createElement('circle', {
104
+ cx: b.x,
105
+ cy: b.y,
106
+ r: 4,
107
+ className: 'squisq-scene-connect-preview',
108
+ }),
109
+ );
110
+ },
111
+ };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * DrawingConnectTool — create and re-target connectors between shapes.
3
+ *
4
+ * Mirrors `ConnectTool` but works on drawing shape layers (`dshape-<id>`)
5
+ * and supports two gestures:
6
+ * - Press empty-on a shape, drag to another shape, release → `addEdge`.
7
+ * - Press on a selected connector's endpoint handle, drag to a shape,
8
+ * release → re-target that endpoint (via the `onRetarget` callback,
9
+ * since the generic SceneCommand vocabulary has no retarget command).
10
+ *
11
+ * Current edges / selection / shape boxes change every render, so the
12
+ * host passes `endpointAt` (reads the live selected-edge handles) rather
13
+ * than baking them into the memoized tool.
14
+ */
15
+
16
+ import { createElement, type JSX } from 'react';
17
+ import type { SceneTool, SceneToolContext } from './SceneTool';
18
+ import { shapeIdFromLayerId, isPrimaryShapeLayer } from '../layers/shapeLayers';
19
+
20
+ /** A grab on the selected connector's endpoint handle. */
21
+ export interface EndpointHit {
22
+ connectorId: string;
23
+ /** Which end the handle controls. */
24
+ end: 'from' | 'to';
25
+ /** The shape id of the *other* (fixed) endpoint — anchors the drag. */
26
+ fixedShapeId: string;
27
+ }
28
+
29
+ export interface DrawingConnectOptions {
30
+ /** Endpoint handle (of the selected connector) near `point`, if any. */
31
+ endpointAt?: (point: { x: number; y: number }) => EndpointHit | null;
32
+ /** Commit an endpoint re-target. */
33
+ onRetarget?: (connectorId: string, end: 'from' | 'to', newTargetId: string) => void;
34
+ }
35
+
36
+ type DragState =
37
+ | {
38
+ mode: 'new';
39
+ sourceId: string;
40
+ anchor: { x: number; y: number };
41
+ current: { x: number; y: number };
42
+ hovered: string | null;
43
+ }
44
+ | {
45
+ mode: 'retarget';
46
+ connectorId: string;
47
+ end: 'from' | 'to';
48
+ fixedShapeId: string;
49
+ anchor: { x: number; y: number };
50
+ current: { x: number; y: number };
51
+ hovered: string | null;
52
+ };
53
+
54
+ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): SceneTool {
55
+ let drag: DragState | null = null;
56
+
57
+ function pointer(e: React.PointerEvent): { sx: number; sy: number } {
58
+ const rect = (e.currentTarget as Element).getBoundingClientRect();
59
+ return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
60
+ }
61
+
62
+ function shapeCenter(ctx: SceneToolContext, shapeId: string): { x: number; y: number } | null {
63
+ const it = ctx.hitItems.find((h) => h.id === `dshape-${shapeId}`);
64
+ if (!it) return null;
65
+ return { x: it.bounds.x + it.bounds.width / 2, y: it.bounds.y + it.bounds.height / 2 };
66
+ }
67
+
68
+ function shapeAt(ctx: SceneToolContext, v: { x: number; y: number }): string | null {
69
+ const hitId = ctx.hit(v);
70
+ if (!hitId || !isPrimaryShapeLayer(hitId)) return null;
71
+ return shapeIdFromLayerId(hitId);
72
+ }
73
+
74
+ return {
75
+ id: 'connect',
76
+ label: 'Connect',
77
+ cursor: 'crosshair',
78
+ shortcut: 'c',
79
+
80
+ onPointerDown(e, ctx) {
81
+ if (e.button !== 0) return;
82
+ const { sx, sy } = pointer(e);
83
+ const v = ctx.screenToViewport(sx, sy);
84
+
85
+ // Endpoint handle of the selected connector → re-target gesture.
86
+ const ep = options.endpointAt?.(v);
87
+ if (ep) {
88
+ const anchor = shapeCenter(ctx, ep.fixedShapeId) ?? v;
89
+ drag = {
90
+ mode: 'retarget',
91
+ connectorId: ep.connectorId,
92
+ end: ep.end,
93
+ fixedShapeId: ep.fixedShapeId,
94
+ anchor,
95
+ current: v,
96
+ hovered: null,
97
+ };
98
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
99
+ e.stopPropagation();
100
+ return;
101
+ }
102
+
103
+ // Otherwise start a new connection from the shape under the pointer.
104
+ const src = shapeAt(ctx, v);
105
+ if (!src) return;
106
+ drag = {
107
+ mode: 'new',
108
+ sourceId: src,
109
+ anchor: shapeCenter(ctx, src) ?? v,
110
+ current: v,
111
+ hovered: null,
112
+ };
113
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
114
+ e.stopPropagation();
115
+ },
116
+
117
+ onPointerMove(e, ctx) {
118
+ if (!drag) return;
119
+ const { sx, sy } = pointer(e);
120
+ const v = ctx.screenToViewport(sx, sy);
121
+ drag.current = v;
122
+ const tgt = shapeAt(ctx, v);
123
+ const exclude = drag.mode === 'new' ? drag.sourceId : drag.fixedShapeId;
124
+ drag.hovered = tgt && tgt !== exclude ? tgt : null;
125
+ },
126
+
127
+ onPointerUp(e, ctx) {
128
+ if (!drag) return;
129
+ const finished = drag;
130
+ drag = null;
131
+ (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
132
+ if (!finished.hovered) return;
133
+ if (finished.mode === 'new') {
134
+ ctx.dispatch({ kind: 'addEdge', source: finished.sourceId, target: finished.hovered });
135
+ } else {
136
+ options.onRetarget?.(finished.connectorId, finished.end, finished.hovered);
137
+ }
138
+ },
139
+
140
+ renderOverlay(): JSX.Element | null {
141
+ if (!drag) return null;
142
+ const a = drag.anchor;
143
+ const b = drag.current;
144
+ return createElement(
145
+ 'g',
146
+ { key: 'drawing-connect-preview' },
147
+ createElement('path', {
148
+ d: `M ${a.x} ${a.y} L ${b.x} ${b.y}`,
149
+ className: 'squisq-scene-connect-preview',
150
+ fill: 'none',
151
+ }),
152
+ createElement('circle', {
153
+ cx: b.x,
154
+ cy: b.y,
155
+ r: 4,
156
+ className: 'squisq-scene-connect-preview',
157
+ }),
158
+ );
159
+ },
160
+ };
161
+ }