@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.
Files changed (186) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +1114 -52
  3. package/dist/index.js +18446 -8050
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/BlockCardView.tsx +121 -0
  12. package/src/BlockPreviewPanel.tsx +69 -0
  13. package/src/BlockPropertiesPopover.tsx +191 -0
  14. package/src/EditorContext.tsx +165 -16
  15. package/src/EditorShell.tsx +265 -144
  16. package/src/FolderView.tsx +131 -0
  17. package/src/Icon.tsx +26 -0
  18. package/src/ImageEditor.tsx +69 -22
  19. package/src/OutlinePanel.tsx +64 -7
  20. package/src/PlainHtmlPreview.tsx +30 -3
  21. package/src/PreviewControls.tsx +491 -122
  22. package/src/PreviewPanel.tsx +15 -9
  23. package/src/RawEditor.tsx +179 -17
  24. package/src/RecorderEntry.tsx +9 -16
  25. package/src/TemplateAnnotation.ts +44 -0
  26. package/src/TemplatePicker.tsx +329 -54
  27. package/src/ThemeCustomizerPanel.tsx +30 -336
  28. package/src/ThemePicker.tsx +112 -3
  29. package/src/TimelineBlockPreview.tsx +37 -0
  30. package/src/TimelineTrack.tsx +671 -0
  31. package/src/Toolbar.tsx +544 -180
  32. package/src/Tooltip.tsx +22 -4
  33. package/src/TransitionPicker.tsx +351 -0
  34. package/src/VersionHistoryPanel.tsx +4 -16
  35. package/src/ViewMenuPanel.tsx +17 -14
  36. package/src/WysiwygEditor.tsx +161 -65
  37. package/src/__tests__/blockProperties.test.ts +92 -0
  38. package/src/__tests__/blockRange.test.ts +105 -0
  39. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  40. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  41. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  42. package/src/__tests__/createShapeLayer.test.ts +46 -0
  43. package/src/__tests__/diffContextSections.test.ts +39 -0
  44. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  45. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  46. package/src/__tests__/editorShellProps.test.tsx +96 -0
  47. package/src/__tests__/embeddedMedia.test.ts +48 -0
  48. package/src/__tests__/headingTransition.test.ts +138 -0
  49. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  50. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  51. package/src/__tests__/previewControls.test.tsx +70 -0
  52. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  53. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  54. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  55. package/src/__tests__/timelineSource.test.ts +134 -0
  56. package/src/__tests__/tiptapBridge.test.ts +92 -0
  57. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  58. package/src/__tests__/tooltip.test.tsx +72 -0
  59. package/src/__tests__/transitionCatalog.test.ts +64 -0
  60. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  61. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  62. package/src/__tests__/useMediaRecorder.test.ts +41 -0
  63. package/src/__tests__/useTimelineClock.test.ts +21 -0
  64. package/src/blockProperties.ts +88 -0
  65. package/src/blockRange.ts +132 -0
  66. package/src/buildPreviewDoc.ts +98 -9
  67. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  68. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  69. package/src/codeContext/CodeContextZones.tsx +121 -0
  70. package/src/codeContext/diffContextSections.ts +38 -0
  71. package/src/codeContext/types.ts +75 -0
  72. package/src/customTemplates/AddBin.tsx +126 -0
  73. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  74. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  75. package/src/customTemplates/LayerToolbar.tsx +580 -0
  76. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  77. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  78. package/src/customTemplates/__tests__/library.test.ts +88 -0
  79. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  80. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  81. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  82. package/src/customTemplates/designer.css +673 -0
  83. package/src/customTemplates/index.ts +31 -0
  84. package/src/customTemplates/library.ts +97 -0
  85. package/src/customTemplates/normalizePositions.ts +75 -0
  86. package/src/customTemplates/shapeDefs.ts +131 -0
  87. package/src/customTemplates/thumbnail.tsx +63 -0
  88. package/src/customTemplates/tokenDefs.ts +60 -0
  89. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  90. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  91. package/src/customThemes/CustomThemeContext.tsx +179 -0
  92. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  93. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  94. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  95. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  96. package/src/customThemes/customThemeLibrary.ts +97 -0
  97. package/src/customThemes/index.ts +31 -0
  98. package/src/customThemes/themeControls.tsx +229 -0
  99. package/src/customThemes/themeDraft.ts +272 -0
  100. package/src/customThemes/useDocCustomThemes.ts +49 -0
  101. package/src/diagram/DiagramCanvas.tsx +240 -0
  102. package/src/diagram/DiagramExtension.ts +209 -0
  103. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  104. package/src/diagram/DiagramWidget.tsx +270 -0
  105. package/src/diagram/diagramCommands.ts +604 -0
  106. package/src/diagram/diagramConstants.ts +17 -0
  107. package/src/diagram/useDiagramData.ts +126 -0
  108. package/src/embeddedMedia.ts +78 -0
  109. package/src/frontmatter.ts +29 -0
  110. package/src/headingTransition.ts +231 -0
  111. package/src/imageEditor/CanvasSurface.tsx +383 -88
  112. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  113. package/src/imageEditor/Toolbar.tsx +229 -16
  114. package/src/imageEditor/createShapeLayer.ts +280 -0
  115. package/src/imageEditor/icons.tsx +34 -114
  116. package/src/imageEditor/image-editor.css +54 -5
  117. package/src/imageEditor/state.ts +23 -3
  118. package/src/index.ts +109 -1
  119. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  120. package/src/recorder/RecorderModal.tsx +120 -53
  121. package/src/recorder/RecorderPanel.tsx +2 -26
  122. package/src/recorder/hooks/useMediaRecorder.ts +17 -11
  123. package/src/recorder/insertMediaBlock.ts +30 -0
  124. package/src/resolveBlockVisual.ts +33 -0
  125. package/src/scene/Scene.tsx +540 -0
  126. package/src/scene/SceneBlockExtension.ts +198 -0
  127. package/src/scene/SceneBlockToolbar.tsx +201 -0
  128. package/src/scene/SceneBlockWidget.tsx +434 -0
  129. package/src/scene/ScenePropsBar.tsx +85 -0
  130. package/src/scene/SceneSelection.tsx +107 -0
  131. package/src/scene/SceneViewport.tsx +102 -0
  132. package/src/scene/ShapePalette.tsx +181 -0
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  134. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  135. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  136. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  137. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  138. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  139. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  140. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  141. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  142. package/src/scene/adapters/blockLayers.ts +159 -0
  143. package/src/scene/commands/SceneCommand.ts +70 -0
  144. package/src/scene/commands/drawingCommands.ts +318 -0
  145. package/src/scene/commands/layoutCommands.ts +301 -0
  146. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  147. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  148. package/src/scene/hooks/useSceneSelection.ts +62 -0
  149. package/src/scene/index.ts +95 -0
  150. package/src/scene/layers/DiagramEdges.tsx +127 -0
  151. package/src/scene/layers/edgeGeometry.ts +77 -0
  152. package/src/scene/layers/nodeCard.tsx +145 -0
  153. package/src/scene/layers/renderLayer.tsx +70 -0
  154. package/src/scene/layers/shapeLayers.ts +201 -0
  155. package/src/scene/paths/bezierEdit.ts +208 -0
  156. package/src/scene/scene.css +649 -0
  157. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  158. package/src/scene/text/sceneTextChannel.ts +40 -0
  159. package/src/scene/text/sceneTextConfig.ts +27 -0
  160. package/src/scene/text/sceneTiptap.ts +36 -0
  161. package/src/scene/text/useSceneTextEditing.ts +39 -0
  162. package/src/scene/tools/ConnectTool.ts +111 -0
  163. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  164. package/src/scene/tools/PathTool.ts +158 -0
  165. package/src/scene/tools/PlaceTool.ts +47 -0
  166. package/src/scene/tools/SceneTool.ts +75 -0
  167. package/src/scene/tools/SelectTool.ts +284 -0
  168. package/src/scene/tools/ShapeTool.ts +144 -0
  169. package/src/scene/tools/TextTool.ts +72 -0
  170. package/src/scene/tools/TokenTool.ts +95 -0
  171. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  172. package/src/styles/code-context.css +155 -0
  173. package/src/styles/diagram.css +183 -0
  174. package/src/styles/editor.css +1708 -150
  175. package/src/styles/folder-view.css +210 -0
  176. package/src/styles/image-edit-affordance.css +2 -2
  177. package/src/styles/index.css +5 -0
  178. package/src/timelineSource.ts +244 -0
  179. package/src/tiptapBridge.ts +115 -34
  180. package/src/tooltipPlacement.ts +13 -0
  181. package/src/transitionCatalog.ts +159 -0
  182. package/src/types/monaco-shims.d.ts +10 -0
  183. package/src/useBlockNavigator.ts +153 -0
  184. package/src/useMonacoLoader.ts +23 -1
  185. package/src/useTimelineClock.ts +76 -0
  186. package/src/utils/dropUtils.ts +1 -1
@@ -8,10 +8,13 @@ import { useRef, useState, useEffect } from 'react';
8
8
  import type { ImageEditDoc } from '@bendyline/squisq/schemas';
9
9
  import type { ImageEditorAction, ImageEditorTool } from './state.js';
10
10
  import { CropIcon, CursorIcon, PlusIcon, ShapeIcon, TextIcon } from './icons.js';
11
+ import { ShapePalette } from '../scene/ShapePalette.js';
11
12
 
12
13
  export interface ToolbarProps {
13
14
  doc: ImageEditDoc;
14
15
  tool: ImageEditorTool;
16
+ /** The currently armed shape palette kind. Used to highlight active redline shortcuts. */
17
+ shapeKind?: string;
15
18
  dispatch: (a: ImageEditorAction) => void;
16
19
  /** Upload an image asset and return its sidecar-relative path. */
17
20
  uploadAsset: (file: Blob, suggestedName?: string) => Promise<string>;
@@ -29,8 +32,98 @@ export interface ToolbarProps {
29
32
  * mount the version-history dropdown when versioning is enabled.
30
33
  */
31
34
  extraTools?: React.ReactNode;
35
+ /** Current zoom level (1 = 100%). */
36
+ zoom?: number;
37
+ onZoomIn?: () => void;
38
+ onZoomOut?: () => void;
39
+ onZoomSet?: (zoom: number) => void;
40
+ onZoomFit?: () => void;
41
+ onZoom1to1?: () => void;
32
42
  }
33
43
 
44
+ function RedlineArrowIcon() {
45
+ return (
46
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
47
+ <line
48
+ x1="3"
49
+ y1="12"
50
+ x2="12"
51
+ y2="3"
52
+ stroke="#cc0000"
53
+ strokeWidth="1.8"
54
+ strokeLinecap="round"
55
+ />
56
+ <polyline
57
+ points="7,3 12,3 12,8"
58
+ stroke="#cc0000"
59
+ strokeWidth="1.8"
60
+ strokeLinejoin="round"
61
+ strokeLinecap="round"
62
+ fill="none"
63
+ />
64
+ </svg>
65
+ );
66
+ }
67
+
68
+ function RedlineRectIcon() {
69
+ return (
70
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
71
+ <rect x="1.5" y="3" width="12" height="9" stroke="#cc0000" strokeWidth="1.8" rx="0.5" />
72
+ </svg>
73
+ );
74
+ }
75
+
76
+ function RedlineTextIcon() {
77
+ return (
78
+ <svg width="15" height="15" viewBox="0 0 15 15" aria-hidden="true">
79
+ <text x="1.5" y="13" fill="#cc0000" fontSize="13" fontFamily="sans-serif" fontWeight="bold">
80
+ A
81
+ </text>
82
+ </svg>
83
+ );
84
+ }
85
+
86
+ function ZoomRectIcon() {
87
+ return (
88
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
89
+ <circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.5" />
90
+ <line
91
+ x1="9.5"
92
+ y1="9.5"
93
+ x2="13.5"
94
+ y2="13.5"
95
+ stroke="currentColor"
96
+ strokeWidth="1.5"
97
+ strokeLinecap="round"
98
+ />
99
+ <line
100
+ x1="4"
101
+ y1="6"
102
+ x2="8"
103
+ y2="6"
104
+ stroke="currentColor"
105
+ strokeWidth="1.2"
106
+ strokeLinecap="round"
107
+ />
108
+ <line
109
+ x1="6"
110
+ y1="4"
111
+ x2="6"
112
+ y2="8"
113
+ stroke="currentColor"
114
+ strokeWidth="1.2"
115
+ strokeLinecap="round"
116
+ />
117
+ </svg>
118
+ );
119
+ }
120
+
121
+ const REDLINE_TOOLS: Array<{ kind: string; icon: React.ReactNode; title: string }> = [
122
+ { kind: 'redline-arrow', icon: <RedlineArrowIcon />, title: 'Redline arrow' },
123
+ { kind: 'redline-rect', icon: <RedlineRectIcon />, title: 'Redline rectangle' },
124
+ { kind: 'redline-text', icon: <RedlineTextIcon />, title: 'Redline text' },
125
+ ];
126
+
34
127
  const TOOLS: Array<{
35
128
  id: ImageEditorTool;
36
129
  icon: React.ReactNode;
@@ -40,11 +133,13 @@ const TOOLS: Array<{
40
133
  { id: 'text', icon: <TextIcon />, title: 'Add text (T)' },
41
134
  { id: 'shape', icon: <ShapeIcon />, title: 'Add shape (S)' },
42
135
  { id: 'crop', icon: <CropIcon />, title: 'Crop (C)' },
136
+ { id: 'zoom-rect', icon: <ZoomRectIcon />, title: 'Zoom to rectangle (Z)' },
43
137
  ];
44
138
 
45
139
  export function Toolbar({
46
140
  doc,
47
141
  tool,
142
+ shapeKind,
48
143
  dispatch,
49
144
  uploadAsset,
50
145
  onExport,
@@ -52,8 +147,15 @@ export function Toolbar({
52
147
  saveLabel = 'Save',
53
148
  saveTitle = 'Save state.json',
54
149
  extraTools,
150
+ zoom,
151
+ onZoomIn,
152
+ onZoomOut,
153
+ onZoomSet,
154
+ onZoomFit,
155
+ onZoom1to1,
55
156
  }: ToolbarProps) {
56
157
  const fileInputRef = useRef<HTMLInputElement | null>(null);
158
+ const [shapePaletteOpen, setShapePaletteOpen] = useState(false);
57
159
 
58
160
  const onFilePicked = async (file: File) => {
59
161
  try {
@@ -87,22 +189,79 @@ export function Toolbar({
87
189
  return (
88
190
  <div className="squisq-image-editor-toolbar" data-testid="image-editor-toolbar">
89
191
  <div className="squisq-image-editor-tool-group" role="radiogroup" aria-label="Tools">
90
- {TOOLS.map((t) => (
91
- <button
92
- key={t.id}
93
- type="button"
94
- role="radio"
95
- aria-checked={tool === t.id}
96
- className={['squisq-image-editor-tool-button', tool === t.id ? 'is-active' : '']
97
- .filter(Boolean)
98
- .join(' ')}
99
- onClick={() => dispatch({ type: 'set-tool', tool: t.id })}
100
- title={t.title}
101
- aria-label={t.title}
102
- >
103
- {t.icon}
104
- </button>
105
- ))}
192
+ {TOOLS.map((t) => {
193
+ const button = (
194
+ <button
195
+ key={t.id}
196
+ type="button"
197
+ role="radio"
198
+ aria-checked={tool === t.id}
199
+ className={['squisq-image-editor-tool-button', tool === t.id ? 'is-active' : '']
200
+ .filter(Boolean)
201
+ .join(' ')}
202
+ onClick={() => {
203
+ if (t.id === 'shape') {
204
+ // The Shape tool owns a palette popover: clicking it both
205
+ // arms the shape tool and toggles the palette so the user
206
+ // can choose which shape to drop next.
207
+ dispatch({ type: 'set-tool', tool: 'shape' });
208
+ setShapePaletteOpen((o) => !o);
209
+ return;
210
+ }
211
+ setShapePaletteOpen(false);
212
+ dispatch({ type: 'set-tool', tool: t.id });
213
+ }}
214
+ title={t.title}
215
+ aria-label={t.title}
216
+ aria-haspopup={t.id === 'shape' ? 'dialog' : undefined}
217
+ aria-expanded={t.id === 'shape' ? shapePaletteOpen : undefined}
218
+ >
219
+ {t.icon}
220
+ </button>
221
+ );
222
+ if (t.id !== 'shape') return button;
223
+ return (
224
+ <span key={t.id} className="squisq-image-editor-shape-trigger">
225
+ {button}
226
+ {shapePaletteOpen && (
227
+ <ShapePalette
228
+ ignoreOutsideSelector=".squisq-image-editor-toolbar"
229
+ onPick={(kind) => {
230
+ dispatch({ type: 'set-shape-kind', kind });
231
+ dispatch({ type: 'set-tool', tool: 'shape' });
232
+ setShapePaletteOpen(false);
233
+ }}
234
+ onClose={() => setShapePaletteOpen(false)}
235
+ />
236
+ )}
237
+ </span>
238
+ );
239
+ })}
240
+ </div>
241
+
242
+ <div className="squisq-image-editor-tool-group" role="group" aria-label="Redline shortcuts">
243
+ {REDLINE_TOOLS.map((rt) => {
244
+ const active = tool === 'shape' && shapeKind === rt.kind;
245
+ return (
246
+ <button
247
+ key={rt.kind}
248
+ type="button"
249
+ className={['squisq-image-editor-tool-button', active ? 'is-active' : '']
250
+ .filter(Boolean)
251
+ .join(' ')}
252
+ onClick={() => {
253
+ dispatch({ type: 'set-shape-kind', kind: rt.kind });
254
+ dispatch({ type: 'set-tool', tool: 'shape' });
255
+ setShapePaletteOpen(false);
256
+ }}
257
+ title={rt.title}
258
+ aria-label={rt.title}
259
+ aria-pressed={active}
260
+ >
261
+ {rt.icon}
262
+ </button>
263
+ );
264
+ })}
106
265
  </div>
107
266
 
108
267
  <div className="squisq-image-editor-tool-group">
@@ -129,6 +288,60 @@ export function Toolbar({
129
288
  />
130
289
  </div>
131
290
 
291
+ {(onZoomIn || onZoomOut) && (
292
+ <div className="squisq-image-editor-tool-group squisq-image-editor-tool-group--zoom">
293
+ <button
294
+ type="button"
295
+ className="squisq-image-editor-tool-button"
296
+ onClick={onZoomOut}
297
+ title="Zoom out"
298
+ aria-label="Zoom out"
299
+ >
300
+
301
+ </button>
302
+ <input
303
+ type="number"
304
+ className="squisq-image-editor-zoom-input"
305
+ value={Math.round((zoom ?? 1) * 100)}
306
+ min={6}
307
+ max={1600}
308
+ step={1}
309
+ onChange={(e) => {
310
+ const v = parseInt(e.target.value, 10);
311
+ if (!isNaN(v) && v > 0) onZoomSet?.(v / 100);
312
+ }}
313
+ aria-label="Zoom percentage"
314
+ title="Zoom %"
315
+ />
316
+ <span className="squisq-image-editor-zoom-label">%</span>
317
+ <button
318
+ type="button"
319
+ className="squisq-image-editor-tool-button"
320
+ onClick={onZoomIn}
321
+ title="Zoom in"
322
+ aria-label="Zoom in"
323
+ >
324
+ +
325
+ </button>
326
+ <button
327
+ type="button"
328
+ className="squisq-image-editor-tool-button squisq-image-editor-tool-button--with-label"
329
+ onClick={onZoom1to1}
330
+ title="1:1 pixels"
331
+ >
332
+ 1:1
333
+ </button>
334
+ <button
335
+ type="button"
336
+ className="squisq-image-editor-tool-button squisq-image-editor-tool-button--with-label"
337
+ onClick={onZoomFit}
338
+ title="Fit to window"
339
+ >
340
+ Fit
341
+ </button>
342
+ </div>
343
+ )}
344
+
132
345
  <div className="squisq-image-editor-tool-group squisq-image-editor-tool-group--right">
133
346
  {extraTools}
134
347
  {onSave && (
@@ -0,0 +1,280 @@
1
+ /**
2
+ * createShapeLayer — turn a drawing-palette shape `kind` and a canvas drop
3
+ * point into a new {@link ImageEditLayerInput}.
4
+ *
5
+ * The mapping mirrors the drawing editor's `shapeToLayers`: the native
6
+ * primitives (`rectangle`/`circle`/`line`) become a core `ShapeLayer`,
7
+ * `text` becomes a `TextLayer`, the line `arrow` becomes a `PathLayer` with
8
+ * an arrowhead marker, and every other kind (polygons, stars, block arrows,
9
+ * curved shapes) becomes a `PathLayer` carrying `shapeKind` so the shared
10
+ * `PathLayer` renderer re-derives the geometry from the position box on
11
+ * move/resize — exactly like drawings.
12
+ */
13
+
14
+ import { shapePath } from '@bendyline/squisq/doc';
15
+ import type { ImageEditLayerInput } from './state.js';
16
+
17
+ /** Default shape footprint, centered on the drop point. */
18
+ const DEFAULT_WIDTH = 120;
19
+ const DEFAULT_HEIGHT = 80;
20
+ const DEFAULT_FILL = '#3399ff';
21
+ const DEFAULT_STROKE = '#1a4d80';
22
+ const DEFAULT_STROKE_WIDTH = 2;
23
+
24
+ /** Redline annotation color (engineering review red). */
25
+ const REDLINE_COLOR = '#cc0000';
26
+ const REDLINE_STROKE_WIDTH = 15;
27
+
28
+ /** Kinds that are drawn by dragging start→end (or corner A→corner B) rather than clicking to drop. */
29
+ const LINEAR_KINDS = new Set(['line', 'arrow', 'redline-arrow', 'redline-rect']);
30
+
31
+ export function isLinearShapeKind(kind: string): boolean {
32
+ return LINEAR_KINDS.has(kind);
33
+ }
34
+
35
+ /** Native `ShapeLayer` primitive kinds (the rest compile to a `PathLayer`). */
36
+ const NATIVE: Record<string, 'rect' | 'circle' | 'line'> = {
37
+ rectangle: 'rect',
38
+ circle: 'circle',
39
+ line: 'line',
40
+ };
41
+
42
+ export function createShapeLayer(kind: string, x: number, y: number): ImageEditLayerInput {
43
+ const width = DEFAULT_WIDTH;
44
+ const height = DEFAULT_HEIGHT;
45
+ const px = Math.round(x - width / 2);
46
+ const py = Math.round(y - height / 2);
47
+ const position = { x: px, y: py, width, height };
48
+ const name = prettifyKind(kind);
49
+
50
+ if (kind === 'text') {
51
+ return {
52
+ type: 'text',
53
+ name: 'Text',
54
+ position: { x: Math.round(x), y: Math.round(y), width: 240, height: 48 },
55
+ content: {
56
+ text: 'New text',
57
+ style: { fontSize: 32, color: '#111111', fontFamily: 'sans-serif' },
58
+ },
59
+ };
60
+ }
61
+
62
+ // ── Redline shortcuts ──────────────────────────────────────────────────────
63
+ if (kind === 'redline-arrow') {
64
+ return {
65
+ type: 'path',
66
+ name: 'Redline Arrow',
67
+ position,
68
+ content: {
69
+ d: `M ${px} ${py} L ${px + width} ${py + height}`,
70
+ stroke: REDLINE_COLOR,
71
+ strokeWidth: REDLINE_STROKE_WIDTH,
72
+ fill: 'none',
73
+ endMarker: 'arrow',
74
+ },
75
+ };
76
+ }
77
+ if (kind === 'redline-rect') {
78
+ return {
79
+ type: 'shape',
80
+ name: 'Redline Rectangle',
81
+ position,
82
+ content: {
83
+ shape: 'rect',
84
+ fill: 'none',
85
+ stroke: REDLINE_COLOR,
86
+ strokeWidth: REDLINE_STROKE_WIDTH,
87
+ borderRadius: 0,
88
+ },
89
+ };
90
+ }
91
+ if (kind === 'redline-text') {
92
+ return {
93
+ type: 'text',
94
+ name: 'Redline Text',
95
+ position: { x: Math.round(x), y: Math.round(y), width: 240, height: 48 },
96
+ content: {
97
+ text: 'Annotation',
98
+ style: {
99
+ fontSize: 24,
100
+ color: REDLINE_COLOR,
101
+ fontFamily: 'sans-serif',
102
+ fontWeight: 'bold',
103
+ },
104
+ },
105
+ };
106
+ }
107
+
108
+ const native = NATIVE[kind];
109
+ if (native === 'rect') {
110
+ return {
111
+ type: 'shape',
112
+ name,
113
+ position,
114
+ content: {
115
+ shape: 'rect',
116
+ fill: DEFAULT_FILL,
117
+ stroke: DEFAULT_STROKE,
118
+ strokeWidth: DEFAULT_STROKE_WIDTH,
119
+ borderRadius: 8,
120
+ },
121
+ };
122
+ }
123
+ if (native === 'circle') {
124
+ return {
125
+ type: 'shape',
126
+ name,
127
+ position,
128
+ content: {
129
+ shape: 'circle',
130
+ fill: DEFAULT_FILL,
131
+ stroke: DEFAULT_STROKE,
132
+ strokeWidth: DEFAULT_STROKE_WIDTH,
133
+ },
134
+ };
135
+ }
136
+ if (native === 'line') {
137
+ return {
138
+ type: 'shape',
139
+ name,
140
+ position,
141
+ content: { shape: 'line', stroke: DEFAULT_STROKE, strokeWidth: DEFAULT_STROKE_WIDTH },
142
+ };
143
+ }
144
+
145
+ if (kind === 'arrow') {
146
+ // A bounding-box line with an arrowhead at the end (no fill).
147
+ return {
148
+ type: 'path',
149
+ name: 'Arrow',
150
+ position,
151
+ content: {
152
+ d: `M ${px} ${py} L ${px + width} ${py + height}`,
153
+ stroke: DEFAULT_STROKE,
154
+ strokeWidth: DEFAULT_STROKE_WIDTH,
155
+ fill: 'none',
156
+ endMarker: 'arrow',
157
+ },
158
+ };
159
+ }
160
+
161
+ // Named polygon / curved shape. Store `shapeKind` so the renderer (and the
162
+ // export pipeline) re-derive `d` from the position box; `d` is a seed value.
163
+ const d =
164
+ shapePath(kind, px, py, width, height) ?? `M ${px} ${py} L ${px + width} ${py + height}`;
165
+ return {
166
+ type: 'path',
167
+ name,
168
+ position,
169
+ content: {
170
+ d,
171
+ shapeKind: kind,
172
+ fill: DEFAULT_FILL,
173
+ stroke: DEFAULT_STROKE,
174
+ strokeWidth: DEFAULT_STROKE_WIDTH,
175
+ },
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Create a line-based shape layer from a drag gesture (start point → end point).
181
+ * Used for `line`, `arrow`, and `redline-arrow` where the user drags to set both
182
+ * endpoints rather than clicking to drop a fixed-size shape.
183
+ */
184
+ export function createLinearShapeLayer(
185
+ kind: string,
186
+ x1: number,
187
+ y1: number,
188
+ x2: number,
189
+ y2: number,
190
+ ): ImageEditLayerInput {
191
+ const rx1 = Math.round(x1);
192
+ const ry1 = Math.round(y1);
193
+ const rx2 = Math.round(x2);
194
+ const ry2 = Math.round(y2);
195
+
196
+ // Bounding box for selection handle placement — always positive dimensions.
197
+ const bx = Math.min(rx1, rx2);
198
+ const by = Math.min(ry1, ry2);
199
+ const bw = Math.max(Math.abs(rx2 - rx1), 4);
200
+ const bh = Math.max(Math.abs(ry2 - ry1), 4);
201
+ const position = { x: bx, y: by, width: bw, height: bh };
202
+
203
+ if (kind === 'line') {
204
+ // ShapeLayer line: renderer draws from (x,y) to (x+width, y+height).
205
+ // Store the actual start/end so the diagonal matches the drag direction.
206
+ return {
207
+ type: 'shape',
208
+ name: 'Line',
209
+ position: {
210
+ x: rx1,
211
+ y: ry1,
212
+ width: rx2 - rx1,
213
+ height: ry2 - ry1,
214
+ },
215
+ content: { shape: 'line', stroke: DEFAULT_STROKE, strokeWidth: DEFAULT_STROKE_WIDTH },
216
+ };
217
+ }
218
+
219
+ if (kind === 'arrow') {
220
+ return {
221
+ type: 'path',
222
+ name: 'Arrow',
223
+ position,
224
+ content: {
225
+ d: `M ${rx1} ${ry1} L ${rx2} ${ry2}`,
226
+ stroke: DEFAULT_STROKE,
227
+ strokeWidth: DEFAULT_STROKE_WIDTH,
228
+ fill: 'none',
229
+ endMarker: 'arrow',
230
+ },
231
+ };
232
+ }
233
+
234
+ if (kind === 'redline-arrow') {
235
+ return {
236
+ type: 'path',
237
+ name: 'Redline Arrow',
238
+ position,
239
+ content: {
240
+ d: `M ${rx1} ${ry1} L ${rx2} ${ry2}`,
241
+ stroke: REDLINE_COLOR,
242
+ strokeWidth: REDLINE_STROKE_WIDTH,
243
+ fill: 'none',
244
+ endMarker: 'arrow',
245
+ },
246
+ };
247
+ }
248
+
249
+ if (kind === 'redline-rect') {
250
+ // Normalize so width/height are always positive regardless of drag direction.
251
+ return {
252
+ type: 'shape',
253
+ name: 'Redline Rectangle',
254
+ position: {
255
+ x: Math.min(rx1, rx2),
256
+ y: Math.min(ry1, ry2),
257
+ width: Math.max(Math.abs(rx2 - rx1), 4),
258
+ height: Math.max(Math.abs(ry2 - ry1), 4),
259
+ },
260
+ content: {
261
+ shape: 'rect',
262
+ fill: 'none',
263
+ stroke: REDLINE_COLOR,
264
+ strokeWidth: REDLINE_STROKE_WIDTH,
265
+ borderRadius: 0,
266
+ },
267
+ };
268
+ }
269
+
270
+ // Fallback: shouldn't be called for non-linear kinds
271
+ return createShapeLayer(kind, (x1 + x2) / 2, (y1 + y2) / 2);
272
+ }
273
+
274
+ /** Turn a palette kind into a layer-panel name, e.g. `arrow-right` → `Arrow Right`. */
275
+ function prettifyKind(kind: string): string {
276
+ return kind
277
+ .split('-')
278
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
279
+ .join(' ');
280
+ }