@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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LayoutAdapter — edit a `{[layout]}` block as semantic markdown.
|
|
3
|
+
*
|
|
4
|
+
* Read direction: child `{[type …]}` headings → `computeLayoutLayers` →
|
|
5
|
+
* Scene layers (text content comes from each child's body).
|
|
6
|
+
* Write direction: SceneCommand → `layoutCommands` (insert/update/delete
|
|
7
|
+
* layer headings + bodies).
|
|
8
|
+
*
|
|
9
|
+
* The layout counterpart to `DrawingAdapter` — same seam
|
|
10
|
+
* (`LayoutAdapterResult`), absolute positioning, and text layers whose rich
|
|
11
|
+
* content lives in the child heading's markdown body. Nothing is persisted as
|
|
12
|
+
* a base64 `layers=` blob anymore; every layer is a heading. Documents that
|
|
13
|
+
* still carry the legacy blob are auto-migrated to child sub-blocks on first
|
|
14
|
+
* mount (see the migration effect).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useEffect, useRef, useState } from 'react';
|
|
18
|
+
import type { ReactNode } from 'react';
|
|
19
|
+
import type { Editor } from '@tiptap/react';
|
|
20
|
+
import type { Layer, Block, TextStyle } from '@bendyline/squisq/schemas';
|
|
21
|
+
import { computeLayoutLayers } from '@bendyline/squisq/doc';
|
|
22
|
+
import type { SceneCommand, SceneEdge } from '../commands/SceneCommand';
|
|
23
|
+
import type { SceneTool, SceneToolContext } from '../tools/SceneTool';
|
|
24
|
+
import { SelectTool } from '../tools/SelectTool';
|
|
25
|
+
import { markdownToTiptap } from '../../tiptapBridge';
|
|
26
|
+
import { readLayersFromHeading, writeLayersToHeading } from './blockLayers';
|
|
27
|
+
import {
|
|
28
|
+
listLayoutChildren,
|
|
29
|
+
addLayoutLayer,
|
|
30
|
+
moveLayoutLayer,
|
|
31
|
+
resizeLayoutLayer,
|
|
32
|
+
setLayoutParam,
|
|
33
|
+
setLayoutText,
|
|
34
|
+
removeLayoutLayer,
|
|
35
|
+
} from '../commands/layoutCommands';
|
|
36
|
+
|
|
37
|
+
export interface LayoutAdapterResult {
|
|
38
|
+
/** Layers to pass to `<Scene layers={...} />`. */
|
|
39
|
+
layers: Layer[];
|
|
40
|
+
/** Tool set the host should expose. */
|
|
41
|
+
tools: SceneTool[];
|
|
42
|
+
/** Command dispatch for `<Scene onCommand={...} />`. */
|
|
43
|
+
dispatch: (cmd: SceneCommand) => void;
|
|
44
|
+
// ── Connector-capable adapters (drawing) set these; layout omits them ──
|
|
45
|
+
/** Connector edges to pass to `<Scene edges={...} />`. */
|
|
46
|
+
edges?: SceneEdge[];
|
|
47
|
+
/** `<Scene layerFollows={...} />` — a label layer follows its shape. */
|
|
48
|
+
layerFollows?: (layerId: string) => string | null;
|
|
49
|
+
/** `<Scene renderExtras={...} />` — draws the connector pass behind layers. */
|
|
50
|
+
renderExtras?: (ctx: SceneToolContext) => ReactNode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LayoutAdapterOptions {
|
|
54
|
+
/** Override the tool set (defaults to `[SelectTool]`). */
|
|
55
|
+
tools?: SceneTool[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Viewport the layout canvas composes against (matches `SceneBlockWidget`). */
|
|
59
|
+
const SCENE_VIEWPORT = { width: 1920, height: 1080 };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a LayoutAdapter for the heading at `headingPos`. Subscribes to the
|
|
63
|
+
* editor's transactions so the returned `layers` reflect the latest doc.
|
|
64
|
+
*/
|
|
65
|
+
export function useLayoutAdapter(
|
|
66
|
+
editor: Editor,
|
|
67
|
+
headingPos: number,
|
|
68
|
+
options: LayoutAdapterOptions = {},
|
|
69
|
+
): LayoutAdapterResult {
|
|
70
|
+
const [, setVersion] = useState(0);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const onUpdate = () => setVersion((v) => v + 1);
|
|
73
|
+
editor.on('transaction', onUpdate);
|
|
74
|
+
return () => {
|
|
75
|
+
editor.off('transaction', onUpdate);
|
|
76
|
+
};
|
|
77
|
+
}, [editor]);
|
|
78
|
+
|
|
79
|
+
// ── Read: child headings → layers ──────────────────────────────
|
|
80
|
+
const node = editor.state.doc.nodeAt(headingPos);
|
|
81
|
+
const children = node ? listLayoutChildren(editor, headingPos) : [];
|
|
82
|
+
const blocks: Block[] = children.map((c) => ({
|
|
83
|
+
id: c.id,
|
|
84
|
+
startTime: 0,
|
|
85
|
+
duration: 0,
|
|
86
|
+
audioSegment: 0,
|
|
87
|
+
...(c.kind ? { template: c.kind } : {}),
|
|
88
|
+
...(Object.keys(c.params).length > 0 ? { templateOverrides: c.params } : {}),
|
|
89
|
+
}));
|
|
90
|
+
const { layers } = computeLayoutLayers(blocks, SCENE_VIEWPORT);
|
|
91
|
+
// Text content comes from the child body (faithful rich HTML + plain text),
|
|
92
|
+
// not the annotation — overwrite what the core derivation left empty.
|
|
93
|
+
for (const l of layers) {
|
|
94
|
+
if (l.type !== 'text') continue;
|
|
95
|
+
const c = children.find((ch) => ch.id === l.id);
|
|
96
|
+
if (!c) continue;
|
|
97
|
+
l.content.text = c.bodyText;
|
|
98
|
+
if (c.bodyHtml.trim()) l.content.html = c.bodyHtml;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Migration: legacy base64 `layers=` blob → child sub-blocks ──
|
|
102
|
+
// SSR never decoded the blob, so this only matters in the editor. Runs
|
|
103
|
+
// at most once: no-op when children already exist or no blob is present.
|
|
104
|
+
const migratedRef = useRef(false);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (migratedRef.current) return;
|
|
107
|
+
const n = editor.state.doc.nodeAt(headingPos);
|
|
108
|
+
if (!n) return;
|
|
109
|
+
if (listLayoutChildren(editor, headingPos).length > 0) {
|
|
110
|
+
migratedRef.current = true;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const legacy = readLayersFromHeading(n);
|
|
114
|
+
if (legacy.length === 0) {
|
|
115
|
+
migratedRef.current = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
migratedRef.current = true;
|
|
119
|
+
for (const layer of legacy) {
|
|
120
|
+
const child = layerToLayoutChild(layer);
|
|
121
|
+
addLayoutLayer(editor, headingPos, layer.id, child.kind, child.params, child.bodyHtml);
|
|
122
|
+
}
|
|
123
|
+
writeLayersToHeading(editor, headingPos, []);
|
|
124
|
+
}, [editor, headingPos]);
|
|
125
|
+
|
|
126
|
+
// ── Write: SceneCommand → layout heading commands ──────────────
|
|
127
|
+
const dispatch = (cmd: SceneCommand) => {
|
|
128
|
+
switch (cmd.kind) {
|
|
129
|
+
case 'moveLayer':
|
|
130
|
+
moveLayoutLayer(editor, headingPos, cmd.id, cmd.x, cmd.y);
|
|
131
|
+
return;
|
|
132
|
+
case 'resizeLayer':
|
|
133
|
+
resizeLayoutLayer(editor, headingPos, cmd.id, cmd.width, cmd.height);
|
|
134
|
+
return;
|
|
135
|
+
case 'addLayer': {
|
|
136
|
+
const child = layerToLayoutChild(cmd.layer);
|
|
137
|
+
const id = cmd.layer.id || nextLayoutId(editor, headingPos, child.kind);
|
|
138
|
+
addLayoutLayer(editor, headingPos, id, child.kind, child.params, child.bodyHtml);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
case 'removeLayer':
|
|
142
|
+
removeLayoutLayer(editor, headingPos, cmd.id);
|
|
143
|
+
return;
|
|
144
|
+
case 'renameLayer':
|
|
145
|
+
// Layout text content lives in the body; treat a rename as a plain
|
|
146
|
+
// text replacement of that body.
|
|
147
|
+
setLayoutText(editor, headingPos, cmd.id, markdownToTiptap(cmd.label));
|
|
148
|
+
return;
|
|
149
|
+
case 'setLayerText':
|
|
150
|
+
setLayoutText(
|
|
151
|
+
editor,
|
|
152
|
+
headingPos,
|
|
153
|
+
cmd.id,
|
|
154
|
+
cmd.html && cmd.html.trim() ? cmd.html : markdownToTiptap(cmd.text),
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
case 'setLayerAttr': {
|
|
158
|
+
const mapped = paramForLayoutPath(cmd.path);
|
|
159
|
+
if (mapped) setLayoutParam(editor, headingPos, cmd.id, mapped, String(cmd.value ?? ''));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
case 'addEdge':
|
|
163
|
+
case 'removeEdge':
|
|
164
|
+
// Layouts don't model edges.
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const _exhaustive: never = cmd;
|
|
168
|
+
void _exhaustive;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
layers,
|
|
173
|
+
tools: options.tools ?? [SelectTool],
|
|
174
|
+
dispatch,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================
|
|
179
|
+
// Layer ↔ child mapping
|
|
180
|
+
// ============================================
|
|
181
|
+
|
|
182
|
+
interface LayoutChildWrite {
|
|
183
|
+
kind: string;
|
|
184
|
+
params: Record<string, string>;
|
|
185
|
+
/** Tiptap HTML for a text layer's body ('' for non-text layers). */
|
|
186
|
+
bodyHtml: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Map a Layer to a `{[type …]}` child heading's kind + params + body HTML. */
|
|
190
|
+
function layerToLayoutChild(layer: Layer): LayoutChildWrite {
|
|
191
|
+
const params = posParams(layer.position);
|
|
192
|
+
if (layer.type === 'text') {
|
|
193
|
+
Object.assign(params, styleParams(layer.content.style));
|
|
194
|
+
const bodyHtml = layer.content.html?.trim()
|
|
195
|
+
? layer.content.html
|
|
196
|
+
: markdownToTiptap(layer.content.text ?? '');
|
|
197
|
+
return { kind: 'text', params, bodyHtml };
|
|
198
|
+
}
|
|
199
|
+
if (layer.type === 'shape') {
|
|
200
|
+
const c = layer.content;
|
|
201
|
+
const kind = c.shape === 'rect' ? 'rectangle' : c.shape; // circle / line keep their name
|
|
202
|
+
if (c.fill) params.fill = c.fill;
|
|
203
|
+
if (c.stroke) params.stroke = c.stroke;
|
|
204
|
+
if (c.strokeWidth != null) params.strokeWidth = String(c.strokeWidth);
|
|
205
|
+
if (c.borderStyle) params.borderStyle = c.borderStyle;
|
|
206
|
+
if (c.borderRadius != null) params.borderRadius = String(c.borderRadius);
|
|
207
|
+
return { kind, params, bodyHtml: '' };
|
|
208
|
+
}
|
|
209
|
+
if (layer.type === 'path') {
|
|
210
|
+
const c = layer.content;
|
|
211
|
+
params.d = c.d;
|
|
212
|
+
if (c.stroke) params.stroke = c.stroke;
|
|
213
|
+
if (c.strokeWidth != null) params.strokeWidth = String(c.strokeWidth);
|
|
214
|
+
if (c.fill) params.fill = c.fill;
|
|
215
|
+
return { kind: 'path', params, bodyHtml: '' };
|
|
216
|
+
}
|
|
217
|
+
if (layer.type === 'image') {
|
|
218
|
+
const c = layer.content;
|
|
219
|
+
params.src = c.src;
|
|
220
|
+
if (c.alt) params.alt = c.alt;
|
|
221
|
+
if (c.fit) params.fit = c.fit;
|
|
222
|
+
return { kind: 'image', params, bodyHtml: '' };
|
|
223
|
+
}
|
|
224
|
+
// Unknown layer types degrade to an empty text box.
|
|
225
|
+
return { kind: 'text', params, bodyHtml: '' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Pull numeric x/y/width/height out of a Layer position into string params. */
|
|
229
|
+
function posParams(pos: Layer['position']): Record<string, string> {
|
|
230
|
+
const out: Record<string, string> = {};
|
|
231
|
+
if (typeof pos.x === 'number') out.x = String(Math.round(pos.x));
|
|
232
|
+
if (typeof pos.y === 'number') out.y = String(Math.round(pos.y));
|
|
233
|
+
if (typeof pos.width === 'number') out.width = String(Math.round(pos.width));
|
|
234
|
+
if (typeof pos.height === 'number') out.height = String(Math.round(pos.height));
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Serialize a text layer's style into annotation params. */
|
|
239
|
+
function styleParams(style: TextStyle): Record<string, string> {
|
|
240
|
+
const p: Record<string, string> = {};
|
|
241
|
+
if (style.fontSize != null) p.fontSize = String(Math.round(style.fontSize));
|
|
242
|
+
if (style.color) p.color = style.color;
|
|
243
|
+
if (style.fontFamily) p.fontFamily = style.fontFamily;
|
|
244
|
+
if (style.fontWeight && style.fontWeight !== 'normal') p.fontWeight = style.fontWeight;
|
|
245
|
+
if (style.fontStyle && style.fontStyle !== 'normal') p.fontStyle = style.fontStyle;
|
|
246
|
+
if (style.textAlign) p.align = style.textAlign;
|
|
247
|
+
if (style.verticalAlign) p.valign = style.verticalAlign;
|
|
248
|
+
if (style.lineHeight != null) p.lineHeight = String(style.lineHeight);
|
|
249
|
+
if (style.padding != null) p.padding = String(style.padding);
|
|
250
|
+
if (style.background) p.background = style.background;
|
|
251
|
+
return p;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Map a `setLayerAttr` dotted path to a layout child param name. */
|
|
255
|
+
function paramForLayoutPath(path: string): string | null {
|
|
256
|
+
switch (path) {
|
|
257
|
+
case 'content.style.textAlign':
|
|
258
|
+
return 'align';
|
|
259
|
+
case 'content.style.verticalAlign':
|
|
260
|
+
return 'valign';
|
|
261
|
+
case 'content.style.fontSize':
|
|
262
|
+
return 'fontSize';
|
|
263
|
+
case 'content.style.color':
|
|
264
|
+
return 'color';
|
|
265
|
+
case 'content.style.fontFamily':
|
|
266
|
+
return 'fontFamily';
|
|
267
|
+
case 'content.style.fontWeight':
|
|
268
|
+
return 'fontWeight';
|
|
269
|
+
case 'content.style.fontStyle':
|
|
270
|
+
return 'fontStyle';
|
|
271
|
+
case 'content.style.lineHeight':
|
|
272
|
+
return 'lineHeight';
|
|
273
|
+
case 'content.style.padding':
|
|
274
|
+
return 'padding';
|
|
275
|
+
case 'content.style.background':
|
|
276
|
+
return 'background';
|
|
277
|
+
case 'content.fill':
|
|
278
|
+
return 'fill';
|
|
279
|
+
case 'content.stroke':
|
|
280
|
+
return 'stroke';
|
|
281
|
+
case 'content.strokeWidth':
|
|
282
|
+
return 'strokeWidth';
|
|
283
|
+
case 'content.borderStyle':
|
|
284
|
+
return 'borderStyle';
|
|
285
|
+
case 'content.borderRadius':
|
|
286
|
+
return 'borderRadius';
|
|
287
|
+
default:
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const KIND_PREFIX: Record<string, string> = {
|
|
293
|
+
text: 'text',
|
|
294
|
+
rectangle: 'box',
|
|
295
|
+
rect: 'box',
|
|
296
|
+
circle: 'circle',
|
|
297
|
+
line: 'line',
|
|
298
|
+
path: 'path',
|
|
299
|
+
image: 'image',
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** A fresh, unique heading id for a new layer, e.g. `text-1`, `box-2`. */
|
|
303
|
+
function nextLayoutId(editor: Editor, parentPos: number, kind: string): string {
|
|
304
|
+
const used = new Set(listLayoutChildren(editor, parentPos).map((c) => c.id));
|
|
305
|
+
const prefix = KIND_PREFIX[kind] ?? (kind.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'layer');
|
|
306
|
+
let i = 1;
|
|
307
|
+
let id = `${prefix}-${i}`;
|
|
308
|
+
while (used.has(id)) id = `${prefix}-${++i}`;
|
|
309
|
+
return id;
|
|
310
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blockLayers — read / decode a block's legacy base64 Layer[] blob.
|
|
3
|
+
*
|
|
4
|
+
* **Legacy / migration only.** Layouts (and drawings) now persist every
|
|
5
|
+
* layer as a readable child sub-block heading (see `layoutCommands.ts` /
|
|
6
|
+
* `drawingCommands.ts`); nothing new is written here. These helpers remain
|
|
7
|
+
* so the editor can *read* documents authored before that change and
|
|
8
|
+
* migrate them to child sub-blocks on first edit (see `LayoutAdapter`'s
|
|
9
|
+
* migration effect). `updateLayer` is still used by the in-memory image
|
|
10
|
+
* editor designer.
|
|
11
|
+
*
|
|
12
|
+
* The legacy encoding piggy-backed on the `data-block-attrs` round-trip
|
|
13
|
+
* path by storing the layers as a base64-JSON Pandoc param named
|
|
14
|
+
* `layers="<...>"`. The bridge round-trips `{#id key="value"}` verbatim,
|
|
15
|
+
* so the markdown surface picked this up for free.
|
|
16
|
+
*
|
|
17
|
+
* Encoding choice — base64 keeps the JSON's commas, quotes, and braces
|
|
18
|
+
* from clashing with the Pandoc attribute tokenizer. Authors who want
|
|
19
|
+
* to hand-edit raw layer arrays can do so by switching to the raw
|
|
20
|
+
* markdown view, decoding, editing, re-encoding; future work may
|
|
21
|
+
* expose this in a more friendly format (e.g. a fenced `~~~layers`
|
|
22
|
+
* block).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Editor } from '@tiptap/react';
|
|
26
|
+
import type { Node as PMNode } from '@tiptap/pm/model';
|
|
27
|
+
import type { Layer } from '@bendyline/squisq/schemas';
|
|
28
|
+
import {
|
|
29
|
+
parsePandocAttrTokens,
|
|
30
|
+
serializePandocAttributes,
|
|
31
|
+
type HeadingAttributes,
|
|
32
|
+
} from '@bendyline/squisq/markdown';
|
|
33
|
+
|
|
34
|
+
const LAYERS_PARAM = 'layers';
|
|
35
|
+
|
|
36
|
+
/** Decode the `layers="..."` param into a Layer[]; returns [] when absent or invalid. */
|
|
37
|
+
export function readLayersFromHeading(node: PMNode): Layer[] {
|
|
38
|
+
const attrs = getHeadingAttrs(node);
|
|
39
|
+
const encoded = attrs.params?.[LAYERS_PARAM];
|
|
40
|
+
if (!encoded) return [];
|
|
41
|
+
return decodeLayers(encoded);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write a Layer[] back into the heading's `data-block-attrs`. */
|
|
45
|
+
export function writeLayersToHeading(
|
|
46
|
+
editor: Editor,
|
|
47
|
+
headingPos: number,
|
|
48
|
+
layers: readonly Layer[],
|
|
49
|
+
): boolean {
|
|
50
|
+
return editor
|
|
51
|
+
.chain()
|
|
52
|
+
.command(({ tr }) => {
|
|
53
|
+
const node = tr.doc.nodeAt(headingPos);
|
|
54
|
+
if (!node || node.type.name !== 'heading') return false;
|
|
55
|
+
const attrs = getHeadingAttrs(node);
|
|
56
|
+
const params = { ...(attrs.params ?? {}) };
|
|
57
|
+
if (layers.length === 0) {
|
|
58
|
+
delete params[LAYERS_PARAM];
|
|
59
|
+
} else {
|
|
60
|
+
params[LAYERS_PARAM] = encodeLayers(layers);
|
|
61
|
+
}
|
|
62
|
+
const nextAttrs: HeadingAttributes = { ...attrs };
|
|
63
|
+
nextAttrs.params = Object.keys(params).length > 0 ? params : undefined;
|
|
64
|
+
// `serializePandocAttributes` returns the full `{…}` block, but
|
|
65
|
+
// `data-block-attrs` stores the INSIDE of those braces (matching the
|
|
66
|
+
// reader `parsePandocAttrTokens` and `tiptapBridge`). Strip them so
|
|
67
|
+
// the value round-trips — otherwise the next read sees `{layers=…}`
|
|
68
|
+
// as a malformed token and drops every layer.
|
|
69
|
+
const raw = serializePandocAttributes(nextAttrs);
|
|
70
|
+
tr.setNodeAttribute(headingPos, 'dataBlockAttrs', stripBraces(raw));
|
|
71
|
+
return true;
|
|
72
|
+
})
|
|
73
|
+
.run();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function utf8ToBase64(str: string): string {
|
|
77
|
+
const bytes = new TextEncoder().encode(str);
|
|
78
|
+
if (typeof globalThis.btoa === 'function') {
|
|
79
|
+
let binary = '';
|
|
80
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
81
|
+
return globalThis.btoa(binary);
|
|
82
|
+
}
|
|
83
|
+
return Buffer.from(bytes).toString('base64');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function base64ToUtf8(b64: string): string {
|
|
87
|
+
if (typeof globalThis.atob === 'function') {
|
|
88
|
+
const binary = globalThis.atob(b64);
|
|
89
|
+
const bytes = new Uint8Array(binary.length);
|
|
90
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
91
|
+
return new TextDecoder().decode(bytes);
|
|
92
|
+
}
|
|
93
|
+
return Buffer.from(b64, 'base64').toString('utf-8');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Base64-encode a Layer array as JSON. UTF-8 safe — handles Unicode layer content. */
|
|
97
|
+
export function encodeLayers(layers: readonly Layer[]): string {
|
|
98
|
+
return utf8ToBase64(JSON.stringify(layers));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Inverse of {@link encodeLayers}. Returns [] when the string isn't valid. */
|
|
102
|
+
export function decodeLayers(encoded: string): Layer[] {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(base64ToUtf8(encoded));
|
|
105
|
+
if (!Array.isArray(parsed)) return [];
|
|
106
|
+
return parsed as Layer[];
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getHeadingAttrs(node: PMNode): HeadingAttributes {
|
|
113
|
+
const raw = (node.attrs as Record<string, unknown>).dataBlockAttrs;
|
|
114
|
+
if (typeof raw === 'string' && raw.length > 0) return parsePandocAttrTokens(raw);
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Strip the wrapping `{…}` from a serialized Pandoc attribute block so it
|
|
120
|
+
* matches the inner form stored in `data-block-attrs`. An empty `{}`
|
|
121
|
+
* marker collapses to `null` (nothing to persist).
|
|
122
|
+
*/
|
|
123
|
+
function stripBraces(s: string | null): string | null {
|
|
124
|
+
if (s == null) return null;
|
|
125
|
+
if (s === '{}') return null;
|
|
126
|
+
if (s.startsWith('{') && s.endsWith('}')) return s.slice(1, -1);
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Apply a per-layer transform to the persisted layer list. Convenience
|
|
132
|
+
* for adapter dispatch functions that need to mutate one layer in place
|
|
133
|
+
* (e.g. `moveLayer`, `setLayerAttr`). The transform receives the
|
|
134
|
+
* matching layer (or `undefined` to insert) and returns the next
|
|
135
|
+
* version (or `null` to remove).
|
|
136
|
+
*/
|
|
137
|
+
export function updateLayer(
|
|
138
|
+
editor: Editor,
|
|
139
|
+
headingPos: number,
|
|
140
|
+
layerId: string,
|
|
141
|
+
transform: (current: Layer | undefined) => Layer | null,
|
|
142
|
+
): boolean {
|
|
143
|
+
const node = editor.state.doc.nodeAt(headingPos);
|
|
144
|
+
if (!node) return false;
|
|
145
|
+
const layers = readLayersFromHeading(node);
|
|
146
|
+
const idx = layers.findIndex((l) => l.id === layerId);
|
|
147
|
+
const current = idx >= 0 ? layers[idx] : undefined;
|
|
148
|
+
const next = transform(current);
|
|
149
|
+
let updated: Layer[];
|
|
150
|
+
if (next == null) {
|
|
151
|
+
updated = layers.filter((l) => l.id !== layerId);
|
|
152
|
+
} else if (idx >= 0) {
|
|
153
|
+
updated = layers.slice();
|
|
154
|
+
updated[idx] = next;
|
|
155
|
+
} else {
|
|
156
|
+
updated = [...layers, next];
|
|
157
|
+
}
|
|
158
|
+
return writeLayersToHeading(editor, headingPos, updated);
|
|
159
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneCommand — the union of edits a Scene tool can request.
|
|
3
|
+
*
|
|
4
|
+
* Tools never mutate state directly. They produce a SceneCommand and the
|
|
5
|
+
* host adapter (DiagramAdapter / LayoutAdapter / DrawingAdapter) decides
|
|
6
|
+
* how to apply it — for diagrams that means a Tiptap transaction that
|
|
7
|
+
* updates a heading's Pandoc attrs; for layouts/drawings it's a write to
|
|
8
|
+
* the heading's `dataLayers` attr.
|
|
9
|
+
*
|
|
10
|
+
* Keeping the command shape adapter-agnostic is what lets the same Scene
|
|
11
|
+
* component drive three different editing surfaces.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Layer } from '@bendyline/squisq/schemas';
|
|
15
|
+
|
|
16
|
+
export type SceneCommand =
|
|
17
|
+
/** Commit a layer's new top-left position (in viewport units). */
|
|
18
|
+
| { kind: 'moveLayer'; id: string; x: number; y: number }
|
|
19
|
+
/** Commit a layer's new size (in viewport units). */
|
|
20
|
+
| { kind: 'resizeLayer'; id: string; width: number; height: number }
|
|
21
|
+
/** Append a new layer to the scene. */
|
|
22
|
+
| { kind: 'addLayer'; layer: Layer }
|
|
23
|
+
/** Remove a layer by id. */
|
|
24
|
+
| { kind: 'removeLayer'; id: string }
|
|
25
|
+
/**
|
|
26
|
+
* Set a single attribute on a layer. `path` is a dotted path into the
|
|
27
|
+
* layer object (e.g. `content.text`, `content.style.fontSize`). The
|
|
28
|
+
* adapter is responsible for translating this to a domain-appropriate
|
|
29
|
+
* write (markdown attr, JSON-encoded override, etc.).
|
|
30
|
+
*/
|
|
31
|
+
| { kind: 'setLayerAttr'; id: string; path: string; value: unknown }
|
|
32
|
+
/** Create an edge between two diagram nodes (diagram mode only). */
|
|
33
|
+
| { kind: 'addEdge'; source: string; target: string; type?: string }
|
|
34
|
+
/** Remove an edge by source/target (and optional type). */
|
|
35
|
+
| { kind: 'removeEdge'; source: string; target: string; type?: string }
|
|
36
|
+
/**
|
|
37
|
+
* Rename a labeled layer (e.g. a diagram node card or a TextLayer). The
|
|
38
|
+
* adapter decides whether this writes to TextLayer.content.text, a
|
|
39
|
+
* heading's text content, or something else.
|
|
40
|
+
*/
|
|
41
|
+
| { kind: 'renameLayer'; id: string; label: string }
|
|
42
|
+
/**
|
|
43
|
+
* Set a text layer's content from the inline editor. `text` is the plain
|
|
44
|
+
* projection (always written); `html` is the optional rich representation
|
|
45
|
+
* (stored by adapters that can persist it — layout). Adapters that only
|
|
46
|
+
* keep plain text (diagram/drawing headings) ignore `html`.
|
|
47
|
+
*/
|
|
48
|
+
| { kind: 'setLayerText'; id: string; text: string; html?: string };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An edge in diagram mode. Lives outside the Layer schema because edges
|
|
52
|
+
* are derived from a node's `connectsTo` rather than being authored as
|
|
53
|
+
* top-level visual layers — the Scene renders them as PathLayers, but
|
|
54
|
+
* the adapter never writes a PathLayer to the markdown.
|
|
55
|
+
*/
|
|
56
|
+
export interface SceneEdge {
|
|
57
|
+
/** Stable edge id (`source->target` or `source->target:type`). */
|
|
58
|
+
id: string;
|
|
59
|
+
source: string;
|
|
60
|
+
target: string;
|
|
61
|
+
/** Optional label, rendered at the midpoint. */
|
|
62
|
+
label?: string;
|
|
63
|
+
/** End-of-line markers. Drawing connectors set these; diagrams default end→arrow. */
|
|
64
|
+
startMarker?: import('@bendyline/squisq/schemas').MarkerStyle;
|
|
65
|
+
endMarker?: import('@bendyline/squisq/schemas').MarkerStyle;
|
|
66
|
+
/** SVG stroke-dasharray for dashed/dotted lines. */
|
|
67
|
+
dasharray?: string;
|
|
68
|
+
/** Per-edge routing override (else the renderer's variant default). */
|
|
69
|
+
routing?: import('@bendyline/squisq/doc').ConnectorRouting;
|
|
70
|
+
}
|