@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.
- package/dist/index.d.ts +757 -20
- package/dist/index.js +16910 -6844
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +143 -0
- package/src/EditorShell.tsx +200 -120
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +38 -3
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +180 -8
- package/src/RawEditor.tsx +216 -29
- 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 +528 -174
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +61 -31
- 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__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -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__/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__/useMediaRecorder.test.ts +24 -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/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 +77 -0
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -2
- 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/diagram.css +183 -0
- package/src/styles/editor.css +1615 -203
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +4 -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 +105 -0
- package/src/useTimelineClock.ts +76 -0
- 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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
}
|