@bendyline/squisq-editor-react 1.6.1 → 2.0.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/README.md +12 -3
- package/dist/index.d.ts +1304 -208
- package/dist/index.js +22838 -14494
- package/dist/index.js.map +1 -1
- package/dist/monaco.d.ts +1 -0
- package/dist/monaco.js +4 -0
- package/dist/monaco.js.map +1 -0
- package/dist/styles/index.css +2384 -408
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +48 -17
- package/src/DocumentSettingsDialog.tsx +13 -21
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +403 -270
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +276 -40
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +466 -93
- package/src/PreviewPanel.tsx +313 -77
- package/src/RawEditor.tsx +13 -2
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +34 -10
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +355 -134
- package/src/ThemeCustomizerPanel.tsx +30 -15
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +719 -238
- package/src/TransitionPicker.tsx +44 -5
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +167 -92
- package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
- package/src/__tests__/blockTagActivity.test.ts +183 -0
- package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
- package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
- package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
- package/src/__tests__/editorScrollSync.test.ts +65 -0
- package/src/__tests__/editorShellProps.test.tsx +390 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +53 -15
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +38 -1
- package/src/__tests__/jsonEditor.test.tsx +56 -1
- package/src/__tests__/layersPanel.test.tsx +75 -5
- package/src/__tests__/majorApiTypes.test.ts +26 -0
- package/src/__tests__/mediaBinDrop.test.tsx +90 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/narrationSave.test.ts +148 -0
- package/src/__tests__/outlinePanel.test.tsx +93 -3
- package/src/__tests__/outlineSource.test.ts +217 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
- package/src/__tests__/presentationMode.test.tsx +290 -0
- package/src/__tests__/previewControls.test.tsx +312 -6
- package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
- package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
- package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
- package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
- package/src/__tests__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/statusBar.test.tsx +27 -0
- package/src/__tests__/teleprompter.test.tsx +190 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +70 -0
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
- package/src/__tests__/transformStyleId.test.ts +13 -0
- package/src/__tests__/useImageEditor.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +75 -0
- package/src/__tests__/viewMenuPanel.test.tsx +130 -0
- package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
- package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
- package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
- package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
- package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
- package/src/asciiDiagram/asciiDiagramData.ts +116 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
- package/src/asciiDiagram/asciiPaste.ts +19 -0
- package/src/blockTagActivity.ts +233 -0
- package/src/buildDocumentPreviewMarkdown.ts +168 -0
- package/src/buildPreviewDoc.ts +19 -12
- package/src/customTemplates/CustomLayoutManager.tsx +3 -2
- package/src/customTemplates/CustomTemplateContext.tsx +6 -0
- package/src/customTemplates/TemplateDesigner.tsx +7 -4
- package/src/customTemplates/__tests__/library.test.ts +8 -0
- package/src/customTemplates/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- package/src/customThemes/CustomThemeContext.tsx +6 -0
- package/src/customThemes/CustomThemeDialog.tsx +35 -5
- package/src/customThemes/ImportThemeSection.tsx +178 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
- package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
- package/src/customThemes/customThemeLibrary.ts +7 -4
- package/src/customThemes/index.ts +7 -1
- package/src/customThemes/themeDraft.ts +23 -7
- package/src/diagram/DiagramCanvas.tsx +9 -4
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/headingTransition.ts +85 -25
- package/src/imageEditor/CanvasSurface.tsx +29 -23
- package/src/imageEditor/LayersPanel.tsx +78 -3
- package/src/imageEditor/Toolbar.tsx +5 -1
- package/src/imageEditor/icons.tsx +4 -0
- package/src/imageEditor/image-editor.css +75 -0
- package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
- package/src/imageEditor/useImageEditor.ts +71 -14
- package/src/index.ts +193 -20
- package/src/jsonEditor/JsonEditorContext.tsx +12 -6
- package/src/jsonEditor/RenderNode.tsx +24 -3
- package/src/jsonEditor/editors.tsx +86 -19
- package/src/mediaEntries.ts +12 -0
- package/src/mediaReferences.ts +299 -0
- package/src/monaco.ts +35 -0
- package/src/monacoWorkers.ts +89 -0
- package/src/outlineSource.ts +171 -0
- package/src/presentation/PresentationMode.tsx +596 -0
- package/src/rawEditorIsolation.ts +18 -0
- package/src/recorder/hooks/useMediaRecorder.ts +97 -53
- package/src/resolveBlockVisual.ts +10 -4
- package/src/scene/Scene.tsx +101 -25
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +11 -4
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/SceneViewport.tsx +7 -3
- package/src/scene/ShapePalette.tsx +17 -2
- package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
- package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/commands/SceneCommand.ts +4 -1
- package/src/scene/index.ts +1 -6
- package/src/scene/layers/DiagramEdges.tsx +24 -9
- package/src/scene/layers/edgeGeometry.ts +31 -4
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +145 -2
- package/src/scene/text/SceneTextOverlay.tsx +5 -5
- package/src/scene/text/sceneTextChannel.ts +26 -20
- package/src/scene/text/sceneTextConfig.ts +4 -0
- package/src/scene/tools/ConnectTool.ts +126 -28
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +10 -0
- package/src/scene/tools/SelectTool.ts +34 -18
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +499 -0
- package/src/styles/editor.css +1566 -219
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +139 -0
- package/src/teleprompter/TeleprompterControls.tsx +218 -0
- package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
- package/src/teleprompter/TeleprompterSurface.tsx +267 -0
- package/src/teleprompter/TeleprompterView.tsx +338 -0
- package/src/teleprompter/canvasRenderer.ts +161 -0
- package/src/teleprompter/floatingWindow.ts +352 -0
- package/src/teleprompter/index.ts +61 -0
- package/src/teleprompter/pcmWorklet.ts +62 -0
- package/src/teleprompter/recording/insertPreamble.ts +80 -0
- package/src/teleprompter/recording/narrationSave.ts +134 -0
- package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
- package/src/teleprompter/scrollModel.ts +85 -0
- package/src/teleprompter/teleprompterTheme.ts +303 -0
- package/src/teleprompter/types.ts +38 -0
- package/src/teleprompter/useFloatingWindow.ts +74 -0
- package/src/teleprompter/useMicAnalysis.ts +211 -0
- package/src/teleprompter/useTeleprompter.ts +421 -0
- package/src/templateContentPreviewResolver.ts +360 -0
- package/src/timeline/TimelineEditorWidget.tsx +702 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
- package/src/timeline/__tests__/timelineOps.test.ts +222 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +367 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +277 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +84 -49
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +240 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +125 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
- package/src/treeview/treeOps.ts +172 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +94 -0
- package/src/treeview/treeViewData.ts +41 -0
- package/src/useMonacoLoader.ts +11 -34
- package/src/wysiwygImageUpload.ts +113 -0
- package/src/diagram/DiagramExtension.ts +0 -209
- package/src/diagram/DiagramWidget.tsx +0 -270
- package/src/diagram/useDiagramData.ts +0 -126
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure edits over the authored ASCII timeline model.
|
|
3
|
+
*
|
|
4
|
+
* The code fence remains the source of truth. Canvas coordinates are
|
|
5
|
+
* normalized to the same global, shared scale used by
|
|
6
|
+
* `asciiTimelineToTemplateData`; commands render the edited model back into
|
|
7
|
+
* canonical fence text after applying one of these operations.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AsciiTimeline,
|
|
12
|
+
AsciiTimelineEvent,
|
|
13
|
+
AsciiTimelineMarker,
|
|
14
|
+
AsciiTimelineSide,
|
|
15
|
+
} from '@bendyline/squisq/doc';
|
|
16
|
+
|
|
17
|
+
const STRUCTURAL_MARKERS = /[●○◉◆◇•]+/gu;
|
|
18
|
+
const LEADING_RAILS = /^[─━═╌╍┄┅┈┉-]+/u;
|
|
19
|
+
const COORDINATE_PRECISION = 1_000;
|
|
20
|
+
const COLUMN_EPSILON = 1 / COORDINATE_PRECISION / 2;
|
|
21
|
+
|
|
22
|
+
export interface TimelineEventPatch {
|
|
23
|
+
label?: string;
|
|
24
|
+
/** `null` clears the optional description. */
|
|
25
|
+
description?: string | null;
|
|
26
|
+
side?: AsciiTimelineSide;
|
|
27
|
+
/** `null` restores the label side/default placement. */
|
|
28
|
+
descriptionSide?: AsciiTimelineSide | null;
|
|
29
|
+
callout?: boolean;
|
|
30
|
+
marker?: AsciiTimelineMarker;
|
|
31
|
+
/** Normalized position on the global timeline scale. */
|
|
32
|
+
position?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AddTimelineEventOptions {
|
|
36
|
+
id?: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
side?: AsciiTimelineSide;
|
|
40
|
+
descriptionSide?: AsciiTimelineSide;
|
|
41
|
+
callout?: boolean;
|
|
42
|
+
marker?: AsciiTimelineMarker;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AddTimelineEventResult {
|
|
46
|
+
timeline: AsciiTimeline;
|
|
47
|
+
eventId: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize editable one-line prose exactly as the canonical core renderer
|
|
52
|
+
* does. This prevents the verified command path from accepting a value that
|
|
53
|
+
* is silently reinterpreted as timeline syntax on its first render.
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeTimelineText(value: string): string {
|
|
56
|
+
const normalized = value
|
|
57
|
+
.normalize('NFC')
|
|
58
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
59
|
+
.replace(STRUCTURAL_MARKERS, '·')
|
|
60
|
+
.replace(/\*/g, '∗')
|
|
61
|
+
.replace(/::/g, '∶')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim();
|
|
64
|
+
|
|
65
|
+
return normalized.replace(LEADING_RAILS, (run) =>
|
|
66
|
+
Array.from(run, (character) => (character === '-' ? '−' : '–')).join(''),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Return a globally unique, renderer-safe event id. */
|
|
71
|
+
export function nextTimelineEventId(timeline: AsciiTimeline, base = 'event'): string {
|
|
72
|
+
const used = new Set(timeline.tracks.flatMap((track) => track.events.map((event) => event.id)));
|
|
73
|
+
const safeBase = safeId(base) || 'event';
|
|
74
|
+
if (!used.has(safeBase)) return safeBase;
|
|
75
|
+
let suffix = 2;
|
|
76
|
+
while (used.has(`${safeBase}-${suffix}`)) suffix++;
|
|
77
|
+
return `${safeBase}-${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Add a visible point to `trackId` at a normalized global rail position.
|
|
82
|
+
* Returns the stable id so the canvas can select/focus the new point after
|
|
83
|
+
* the fence rewrite.
|
|
84
|
+
*/
|
|
85
|
+
export function addTimelineEventOp(
|
|
86
|
+
timeline: AsciiTimeline,
|
|
87
|
+
trackId: string,
|
|
88
|
+
position: number,
|
|
89
|
+
options: AddTimelineEventOptions = {},
|
|
90
|
+
): AddTimelineEventResult | null {
|
|
91
|
+
if (!Number.isFinite(position)) return null;
|
|
92
|
+
const track = timeline.tracks.find((candidate) => candidate.id === trackId);
|
|
93
|
+
if (!track) return null;
|
|
94
|
+
|
|
95
|
+
const column = columnAtPosition(timeline, position);
|
|
96
|
+
// Two coincident markers cannot be independently hit on the canvas. A
|
|
97
|
+
// marker click should select the existing point rather than adding atop it.
|
|
98
|
+
if (track.events.some((event) => Math.abs(event.column - column) <= COLUMN_EPSILON)) return null;
|
|
99
|
+
|
|
100
|
+
const label = sanitizeTimelineText(options.label ?? '') || 'New event';
|
|
101
|
+
const description = sanitizeTimelineText(options.description ?? '');
|
|
102
|
+
const eventId = nextTimelineEventId(timeline, options.id ?? label);
|
|
103
|
+
const insertionIndex = track.events.filter((event) => event.column < column).length;
|
|
104
|
+
const side = options.side ?? (insertionIndex % 2 === 0 ? 'above' : 'below');
|
|
105
|
+
const event: AsciiTimelineEvent = {
|
|
106
|
+
id: eventId,
|
|
107
|
+
label,
|
|
108
|
+
...(description ? { description } : {}),
|
|
109
|
+
column,
|
|
110
|
+
side,
|
|
111
|
+
...(description && options.descriptionSide ? { descriptionSide: options.descriptionSide } : {}),
|
|
112
|
+
...(options.callout === false ? { callout: false } : {}),
|
|
113
|
+
marker: options.marker ?? 'filled',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const events = [...track.events, event].sort(
|
|
117
|
+
(left, right) => left.column - right.column || left.id.localeCompare(right.id),
|
|
118
|
+
);
|
|
119
|
+
const tracks = timeline.tracks.map((candidate) =>
|
|
120
|
+
candidate.id === trackId ? { ...candidate, events } : candidate,
|
|
121
|
+
);
|
|
122
|
+
// ASCII art has no distinct diamond glyph. Promote the authored vocabulary
|
|
123
|
+
// when the inspector explicitly requests one rather than silently folding
|
|
124
|
+
// the user's choice back to a filled point in the renderer.
|
|
125
|
+
const style = options.marker === 'diamond' ? 'unicode' : timeline.style;
|
|
126
|
+
return { timeline: { ...timeline, tracks, style }, eventId };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Update one point without changing its stable id or branch endpoints. */
|
|
130
|
+
export function updateTimelineEventOp(
|
|
131
|
+
timeline: AsciiTimeline,
|
|
132
|
+
eventId: string,
|
|
133
|
+
patch: TimelineEventPatch,
|
|
134
|
+
): AsciiTimeline {
|
|
135
|
+
const location = findEvent(timeline, eventId);
|
|
136
|
+
if (!location || Object.keys(patch).length === 0) return timeline;
|
|
137
|
+
|
|
138
|
+
const hasLabel = Object.prototype.hasOwnProperty.call(patch, 'label');
|
|
139
|
+
const hasDescription = Object.prototype.hasOwnProperty.call(patch, 'description');
|
|
140
|
+
const hasDescriptionSide = Object.prototype.hasOwnProperty.call(patch, 'descriptionSide');
|
|
141
|
+
const hasPosition = Object.prototype.hasOwnProperty.call(patch, 'position');
|
|
142
|
+
const label = hasLabel ? sanitizeTimelineText(patch.label ?? '') : location.event.label;
|
|
143
|
+
if (!label) return timeline;
|
|
144
|
+
|
|
145
|
+
let description = hasDescription
|
|
146
|
+
? sanitizeTimelineText(patch.description ?? '')
|
|
147
|
+
: location.event.description;
|
|
148
|
+
if (!description) description = undefined;
|
|
149
|
+
|
|
150
|
+
let column = location.event.column;
|
|
151
|
+
if (hasPosition) {
|
|
152
|
+
if (!Number.isFinite(patch.position)) return timeline;
|
|
153
|
+
column = columnAtPosition(timeline, patch.position as number);
|
|
154
|
+
const collision = location.track.events.some(
|
|
155
|
+
(event) => event.id !== eventId && Math.abs(event.column - column) <= COLUMN_EPSILON,
|
|
156
|
+
);
|
|
157
|
+
if (collision) return timeline;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const editedText = hasLabel || hasDescription;
|
|
161
|
+
const callout =
|
|
162
|
+
patch.callout !== undefined
|
|
163
|
+
? patch.callout
|
|
164
|
+
: editedText && location.event.callout === false
|
|
165
|
+
? true
|
|
166
|
+
: location.event.callout;
|
|
167
|
+
const descriptionSide = !description
|
|
168
|
+
? undefined
|
|
169
|
+
: hasDescriptionSide
|
|
170
|
+
? (patch.descriptionSide ?? undefined)
|
|
171
|
+
: location.event.descriptionSide;
|
|
172
|
+
|
|
173
|
+
const nextEvent: AsciiTimelineEvent = {
|
|
174
|
+
...location.event,
|
|
175
|
+
label,
|
|
176
|
+
column,
|
|
177
|
+
...(description ? { description } : {}),
|
|
178
|
+
...(patch.side ? { side: patch.side } : {}),
|
|
179
|
+
...(descriptionSide ? { descriptionSide } : {}),
|
|
180
|
+
...(callout !== undefined ? { callout } : {}),
|
|
181
|
+
...(patch.marker ? { marker: patch.marker } : {}),
|
|
182
|
+
};
|
|
183
|
+
if (!description) delete nextEvent.description;
|
|
184
|
+
if (!descriptionSide) delete nextEvent.descriptionSide;
|
|
185
|
+
|
|
186
|
+
if (eventsEqual(location.event, nextEvent)) return timeline;
|
|
187
|
+
const events = location.track.events
|
|
188
|
+
.map((event) => (event.id === eventId ? nextEvent : event))
|
|
189
|
+
.sort((left, right) => left.column - right.column || left.id.localeCompare(right.id));
|
|
190
|
+
// A point authored exactly on a track boundary anchors that edge. Let the
|
|
191
|
+
// old scale remain stable throughout a drag preview, then move/reassign the
|
|
192
|
+
// boundary in the single drop transaction. Crossing the next point hands
|
|
193
|
+
// the edge to that point; moving any point outside a shorter track expands
|
|
194
|
+
// the track so every marker remains inside its authored bounds.
|
|
195
|
+
const movedPosition = hasPosition && nextEvent.column !== location.event.column;
|
|
196
|
+
const anchoredStart =
|
|
197
|
+
movedPosition && Math.abs(location.event.column - location.track.startColumn) <= COLUMN_EPSILON;
|
|
198
|
+
const anchoredEnd =
|
|
199
|
+
movedPosition && Math.abs(location.event.column - location.track.endColumn) <= COLUMN_EPSILON;
|
|
200
|
+
const eventStart = Math.min(...events.map((event) => event.column));
|
|
201
|
+
const eventEnd = Math.max(...events.map((event) => event.column));
|
|
202
|
+
const startColumn = anchoredStart ? eventStart : Math.min(location.track.startColumn, eventStart);
|
|
203
|
+
const endColumn = anchoredEnd ? eventEnd : Math.max(location.track.endColumn, eventEnd);
|
|
204
|
+
const tracks = timeline.tracks.map((track) =>
|
|
205
|
+
track.id === location.track.id ? { ...track, startColumn, endColumn, events } : track,
|
|
206
|
+
);
|
|
207
|
+
const style = patch.marker === 'diamond' ? 'unicode' : timeline.style;
|
|
208
|
+
return { ...timeline, tracks, style };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Remove a point and every incident branch. Empty tracks are removed because
|
|
213
|
+
* the canonical renderer cannot represent them. The last point in the whole
|
|
214
|
+
* timeline is retained so an edit cannot erase its own source fence/widget.
|
|
215
|
+
*/
|
|
216
|
+
export function removeTimelineEventOp(timeline: AsciiTimeline, eventId: string): AsciiTimeline {
|
|
217
|
+
const location = findEvent(timeline, eventId);
|
|
218
|
+
if (!location) return timeline;
|
|
219
|
+
const eventCount = timeline.tracks.reduce((sum, track) => sum + track.events.length, 0);
|
|
220
|
+
if (eventCount <= 1) return timeline;
|
|
221
|
+
|
|
222
|
+
const tracks = timeline.tracks
|
|
223
|
+
.map((track) =>
|
|
224
|
+
track.id === location.track.id
|
|
225
|
+
? { ...track, events: track.events.filter((event) => event.id !== eventId) }
|
|
226
|
+
: track,
|
|
227
|
+
)
|
|
228
|
+
.filter((track) => track.events.length > 0);
|
|
229
|
+
const links = timeline.links.filter((link) => link.source !== eventId && link.target !== eventId);
|
|
230
|
+
return { ...timeline, tracks, links };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function globalBounds(timeline: AsciiTimeline): { start: number; span: number } {
|
|
234
|
+
if (timeline.tracks.length === 0) return { start: 0, span: 1 };
|
|
235
|
+
const start = Math.min(...timeline.tracks.map((track) => track.startColumn));
|
|
236
|
+
const end = Math.max(...timeline.tracks.map((track) => track.endColumn));
|
|
237
|
+
return { start, span: Math.max(1, end - start) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function columnAtPosition(timeline: AsciiTimeline, position: number): number {
|
|
241
|
+
const { start, span } = globalBounds(timeline);
|
|
242
|
+
const clamped = Math.max(0, Math.min(1, position));
|
|
243
|
+
return Math.round((start + clamped * span) * COORDINATE_PRECISION) / COORDINATE_PRECISION;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function safeId(value: string): string {
|
|
247
|
+
return (
|
|
248
|
+
sanitizeTimelineText(value)
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.replace(/[^a-z0-9_.~-]+/g, '-')
|
|
251
|
+
.replace(/^-+|-+$/g, '') || 'event'
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function findEvent(
|
|
256
|
+
timeline: AsciiTimeline,
|
|
257
|
+
eventId: string,
|
|
258
|
+
): { track: AsciiTimeline['tracks'][number]; event: AsciiTimelineEvent } | null {
|
|
259
|
+
for (const track of timeline.tracks) {
|
|
260
|
+
const event = track.events.find((candidate) => candidate.id === eventId);
|
|
261
|
+
if (event) return { track, event };
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function eventsEqual(left: AsciiTimelineEvent, right: AsciiTimelineEvent): boolean {
|
|
267
|
+
return (
|
|
268
|
+
left.id === right.id &&
|
|
269
|
+
left.label === right.label &&
|
|
270
|
+
left.description === right.description &&
|
|
271
|
+
left.column === right.column &&
|
|
272
|
+
left.side === right.side &&
|
|
273
|
+
left.descriptionSide === right.descriptionSide &&
|
|
274
|
+
left.callout === right.callout &&
|
|
275
|
+
left.marker === right.marker
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Paste gate for bare, high-confidence Unicode timeline art. */
|
|
2
|
+
|
|
3
|
+
import { detectAsciiTimeline } from '@bendyline/squisq/doc';
|
|
4
|
+
|
|
5
|
+
export function shouldPasteAsTimelineFence(text: string): boolean {
|
|
6
|
+
if (!text.trim() || /^\s*```/m.test(text)) return false;
|
|
7
|
+
return detectAsciiTimeline(text).isTimeline;
|
|
8
|
+
}
|
package/src/tiptapBridge.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
* using squisq's own parser.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { templateLabel } from './TemplatePicker';
|
|
15
14
|
import { resolveIcon } from '@bendyline/squisq/icons';
|
|
16
15
|
import {
|
|
17
16
|
matchTrailingTemplateAnnotation,
|
|
@@ -85,12 +84,28 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
85
84
|
let listType: 'ul' | 'ol' | 'task' = 'ul';
|
|
86
85
|
let inTable = false;
|
|
87
86
|
let tableLines: string[] = [];
|
|
87
|
+
let pendingBlankLines = 0;
|
|
88
|
+
|
|
89
|
+
const flushPendingBlankParagraphs = () => {
|
|
90
|
+
if (pendingBlankLines === 0) return;
|
|
91
|
+
const emptyParagraphs =
|
|
92
|
+
outputBlocks.length === 0 ? pendingBlankLines : Math.max(0, pendingBlankLines - 1);
|
|
93
|
+
for (let i = 0; i < emptyParagraphs; i++) {
|
|
94
|
+
outputBlocks.push('<p></p>');
|
|
95
|
+
}
|
|
96
|
+
pendingBlankLines = 0;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const pushBlock = (block: string) => {
|
|
100
|
+
flushPendingBlankParagraphs();
|
|
101
|
+
outputBlocks.push(block);
|
|
102
|
+
};
|
|
88
103
|
|
|
89
104
|
const flushList = () => {
|
|
90
105
|
if (inList && listItems.length > 0) {
|
|
91
106
|
const tag = listType === 'ol' ? 'ol' : 'ul';
|
|
92
107
|
const attr = listType === 'task' ? ' data-type="taskList"' : '';
|
|
93
|
-
|
|
108
|
+
pushBlock(`<${tag}${attr}>${listItems.join('')}</${tag}>`);
|
|
94
109
|
listItems = [];
|
|
95
110
|
inList = false;
|
|
96
111
|
}
|
|
@@ -111,7 +126,7 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
111
126
|
if (tableLines.length < 2 || !isSeparator) {
|
|
112
127
|
// Not a valid table — render accumulated lines as paragraphs
|
|
113
128
|
for (const tl of tableLines) {
|
|
114
|
-
|
|
129
|
+
pushBlock(`<p>${inlineToHtml(tl)}</p>`);
|
|
115
130
|
}
|
|
116
131
|
inTable = false;
|
|
117
132
|
tableLines = [];
|
|
@@ -146,7 +161,7 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
146
161
|
})
|
|
147
162
|
.join('');
|
|
148
163
|
|
|
149
|
-
|
|
164
|
+
pushBlock(`<table><thead><tr>${thHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>`);
|
|
150
165
|
|
|
151
166
|
inTable = false;
|
|
152
167
|
tableLines = [];
|
|
@@ -159,15 +174,14 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
159
174
|
if (line.startsWith('```')) {
|
|
160
175
|
if (!inCodeBlock) {
|
|
161
176
|
flushList();
|
|
177
|
+
flushPendingBlankParagraphs();
|
|
162
178
|
inCodeBlock = true;
|
|
163
179
|
codeBlockLang = line.slice(3).trim();
|
|
164
180
|
codeBlockLines = [];
|
|
165
181
|
continue;
|
|
166
182
|
} else {
|
|
167
183
|
const langAttr = codeBlockLang ? ` class="language-${escapeHtml(codeBlockLang)}"` : '';
|
|
168
|
-
|
|
169
|
-
`<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`,
|
|
170
|
-
);
|
|
184
|
+
pushBlock(`<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`);
|
|
171
185
|
inCodeBlock = false;
|
|
172
186
|
codeBlockLang = '';
|
|
173
187
|
codeBlockLines = [];
|
|
@@ -188,9 +202,12 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
188
202
|
// Blank line flushes list
|
|
189
203
|
if (line.trim() === '') {
|
|
190
204
|
flushList();
|
|
205
|
+
pendingBlankLines++;
|
|
191
206
|
continue;
|
|
192
207
|
}
|
|
193
208
|
|
|
209
|
+
flushPendingBlankParagraphs();
|
|
210
|
+
|
|
194
211
|
// Headings
|
|
195
212
|
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
196
213
|
if (headingMatch) {
|
|
@@ -233,8 +250,16 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
233
250
|
// tokens are stored raw — quotes included — so tiptapToMarkdown
|
|
234
251
|
// can re-join them into the annotation verbatim.
|
|
235
252
|
const tokens = tokenizeAttrTokens(templateInner);
|
|
236
|
-
|
|
237
|
-
|
|
253
|
+
if (tokens.length === 0) {
|
|
254
|
+
// Preserve an authored `{[]}` through the editable Tiptap document
|
|
255
|
+
// while keeping the raw marker out of the visible heading text.
|
|
256
|
+
attrs += ' data-template-empty="true"';
|
|
257
|
+
}
|
|
258
|
+
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
259
|
+
if (!firstIsParam && tokens[0]) {
|
|
260
|
+
attrs += ` data-template="${escapeHtml(tokens[0])}"`;
|
|
261
|
+
}
|
|
262
|
+
const params = tokens.slice(firstIsParam ? 0 : 1).filter((t) => t.includes('='));
|
|
238
263
|
if (params.length > 0) {
|
|
239
264
|
attrs += ` data-template-params="${escapeHtml(params.join(' '))}"`;
|
|
240
265
|
}
|
|
@@ -243,21 +268,21 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
243
268
|
attrs += ` data-block-attrs="${escapeHtml(pandocInner)}"`;
|
|
244
269
|
}
|
|
245
270
|
|
|
246
|
-
|
|
271
|
+
pushBlock(`<h${level}${attrs}>${inlineToHtml(text)}</h${level}>`);
|
|
247
272
|
continue;
|
|
248
273
|
}
|
|
249
274
|
|
|
250
275
|
// Thematic break
|
|
251
276
|
if (/^(---|\*\*\*|___)(\s*)$/.test(line.trim())) {
|
|
252
277
|
flushList();
|
|
253
|
-
|
|
278
|
+
pushBlock('<hr>');
|
|
254
279
|
continue;
|
|
255
280
|
}
|
|
256
281
|
|
|
257
282
|
// Blockquote
|
|
258
283
|
if (line.startsWith('> ')) {
|
|
259
284
|
flushList();
|
|
260
|
-
|
|
285
|
+
pushBlock(`<blockquote><p>${inlineToHtml(line.slice(2))}</p></blockquote>`);
|
|
261
286
|
continue;
|
|
262
287
|
}
|
|
263
288
|
|
|
@@ -325,7 +350,7 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
325
350
|
flushList();
|
|
326
351
|
const alt = escapeHtml(standaloneImageMatch[1] ?? '');
|
|
327
352
|
const src = escapeHtml(standaloneImageMatch[2] ?? '');
|
|
328
|
-
|
|
353
|
+
pushBlock(`<img alt="${alt}" src="${src}">`);
|
|
329
354
|
continue;
|
|
330
355
|
}
|
|
331
356
|
|
|
@@ -337,7 +362,7 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
337
362
|
const trimmed = line.trim();
|
|
338
363
|
if (/^<img\b[^>]*>$/i.test(trimmed)) {
|
|
339
364
|
flushList();
|
|
340
|
-
|
|
365
|
+
pushBlock(trimmed);
|
|
341
366
|
continue;
|
|
342
367
|
}
|
|
343
368
|
|
|
@@ -348,24 +373,23 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
348
373
|
// picks up the tag attributes (`src`, `controls`, …).
|
|
349
374
|
if (/^<(?:video|audio)\b[^>]*>(?:[\s\S]*?<\/(?:video|audio)>)?$/i.test(trimmed)) {
|
|
350
375
|
flushList();
|
|
351
|
-
|
|
376
|
+
pushBlock(trimmed);
|
|
352
377
|
continue;
|
|
353
378
|
}
|
|
354
379
|
|
|
355
380
|
// Regular paragraph
|
|
356
381
|
flushList();
|
|
357
|
-
|
|
382
|
+
pushBlock(`<p>${inlineToHtml(line)}</p>`);
|
|
358
383
|
}
|
|
359
384
|
|
|
360
385
|
// Close any remaining open blocks
|
|
361
386
|
if (inCodeBlock) {
|
|
362
387
|
const langAttr = codeBlockLang ? ` class="language-${escapeHtml(codeBlockLang)}"` : '';
|
|
363
|
-
|
|
364
|
-
`<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`,
|
|
365
|
-
);
|
|
388
|
+
pushBlock(`<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`);
|
|
366
389
|
}
|
|
367
390
|
flushList();
|
|
368
391
|
flushTable();
|
|
392
|
+
flushPendingBlankParagraphs();
|
|
369
393
|
|
|
370
394
|
return outputBlocks.join('') || '<p></p>';
|
|
371
395
|
}
|
|
@@ -389,33 +413,36 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
389
413
|
if (headingMatch) {
|
|
390
414
|
const level = parseInt(headingMatch[1], 10);
|
|
391
415
|
const attrs = headingMatch[2];
|
|
392
|
-
|
|
416
|
+
const headingHtml = headingMatch[3];
|
|
417
|
+
// Template/property badges are editor chrome, not heading content. Strip
|
|
418
|
+
// them structurally before converting the inline HTML. Older builds
|
|
419
|
+
// briefly rendered the template label as real badge text; using the
|
|
420
|
+
// markup boundary preserves that cleanup without mistaking a legitimate
|
|
421
|
+
// heading suffix (for example "A Famous Quote") for the badge label.
|
|
422
|
+
const chromeStart = headingHtml.search(
|
|
423
|
+
/<span\b[^>]*\bclass="[^"]*\bsquisq-(?:template|props)-badge\b[^"]*"[^>]*>/i,
|
|
424
|
+
);
|
|
425
|
+
let text = htmlToInline(chromeStart >= 0 ? headingHtml.slice(0, chromeStart) : headingHtml);
|
|
393
426
|
|
|
394
427
|
// Re-inject heading annotations from data attributes. Canonical
|
|
395
|
-
// emit order: Pandoc `{#…}` first, then squisq `{[…]}`
|
|
396
|
-
//
|
|
428
|
+
// emit order: Pandoc `{#…}` first, then squisq `{[…]}` annotation
|
|
429
|
+
// (matches blockToMdast in core/markdown/convert.ts).
|
|
397
430
|
const blockAttrsMatch = attrs.match(/data-block-attrs="([^"]*)"/);
|
|
398
431
|
const tmplMatch = attrs.match(/data-template="([^"]+)"/);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// earlier broken build briefly rendered as real text inside the
|
|
402
|
-
// badge. Existing documents self-heal on save.
|
|
403
|
-
const label = templateLabel(tmplMatch[1]);
|
|
404
|
-
if (label && text.endsWith(label)) {
|
|
405
|
-
text = text.slice(0, -label.length).trimEnd();
|
|
406
|
-
}
|
|
407
|
-
}
|
|
432
|
+
const paramsMatch = attrs.match(/data-template-params="([^"]+)"/);
|
|
433
|
+
const hasEmptyTemplateAnnotation = /\sdata-template-empty(?:="[^"]*")?/.test(attrs);
|
|
408
434
|
if (blockAttrsMatch) {
|
|
409
435
|
const inner = unescapeHtml(blockAttrsMatch[1]);
|
|
410
436
|
text += ` {${inner}}`;
|
|
411
437
|
}
|
|
412
|
-
if (tmplMatch) {
|
|
413
|
-
let annotation = tmplMatch[1];
|
|
414
|
-
const paramsMatch = attrs.match(/data-template-params="([^"]+)"/);
|
|
438
|
+
if (tmplMatch || paramsMatch) {
|
|
439
|
+
let annotation = tmplMatch ? tmplMatch[1] : '';
|
|
415
440
|
if (paramsMatch) {
|
|
416
|
-
annotation += ' ' + unescapeHtml(paramsMatch[1]);
|
|
441
|
+
annotation += (annotation ? ' ' : '') + unescapeHtml(paramsMatch[1]);
|
|
417
442
|
}
|
|
418
443
|
text += ` {[${annotation}]}`;
|
|
444
|
+
} else if (hasEmptyTemplateAnnotation) {
|
|
445
|
+
text += ' {[]}';
|
|
419
446
|
}
|
|
420
447
|
|
|
421
448
|
lines.push('#'.repeat(level) + ' ' + text);
|
|
@@ -424,12 +451,13 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
424
451
|
continue;
|
|
425
452
|
}
|
|
426
453
|
|
|
427
|
-
// Code blocks
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
454
|
+
// Code blocks — tolerate attributes on <pre> and <code>: real
|
|
455
|
+
// editor.getHTML() output carries class="squisq-code-block" on the
|
|
456
|
+
// <pre> (StarterKit codeBlock HTMLAttributes), which a bare
|
|
457
|
+
// `<pre><code` anchor never matches, silently dropping the fence.
|
|
458
|
+
const codeMatch = remaining.match(/^<pre\b[^>]*><code\b([^>]*)>(.*?)<\/code><\/pre>/s);
|
|
431
459
|
if (codeMatch) {
|
|
432
|
-
const lang = codeMatch[1]
|
|
460
|
+
const lang = /\bclass="language-([^"]*)"/.exec(codeMatch[1] ?? '')?.[1] ?? '';
|
|
433
461
|
const code = unescapeHtml(codeMatch[2]);
|
|
434
462
|
lines.push('```' + lang);
|
|
435
463
|
lines.push(code);
|
|
@@ -575,6 +603,8 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
575
603
|
if (text.trim()) {
|
|
576
604
|
lines.push(text);
|
|
577
605
|
lines.push('');
|
|
606
|
+
} else {
|
|
607
|
+
lines.push('');
|
|
578
608
|
}
|
|
579
609
|
remaining = remaining.slice(pMatch[0].length);
|
|
580
610
|
continue;
|
|
@@ -633,13 +663,7 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
633
663
|
remaining = remaining.slice(1);
|
|
634
664
|
}
|
|
635
665
|
|
|
636
|
-
|
|
637
|
-
return (
|
|
638
|
-
lines
|
|
639
|
-
.join('\n')
|
|
640
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
641
|
-
.trim() + '\n'
|
|
642
|
-
);
|
|
666
|
+
return lines.join('\n');
|
|
643
667
|
}
|
|
644
668
|
|
|
645
669
|
/**
|
|
@@ -793,6 +817,7 @@ function unescapeHtml(text: string): string {
|
|
|
793
817
|
.replace(/</g, '<')
|
|
794
818
|
.replace(/>/g, '>')
|
|
795
819
|
.replace(/"/g, '"')
|
|
820
|
+
.replace(/ | | /gi, '\u00a0')
|
|
796
821
|
.replace(/&/g, '&');
|
|
797
822
|
}
|
|
798
823
|
|
|
@@ -869,7 +894,7 @@ function inlineToHtml(text: string): string {
|
|
|
869
894
|
// eslint-disable-next-line no-control-regex
|
|
870
895
|
result = result.replace(/\u0000PH(\d+)\u0000/g, (_m, idx) => placeholders[Number(idx)] ?? '');
|
|
871
896
|
|
|
872
|
-
return result;
|
|
897
|
+
return preserveLeadingSpaces(result);
|
|
873
898
|
}
|
|
874
899
|
|
|
875
900
|
/** Convert inline HTML back to markdown */
|
|
@@ -929,5 +954,15 @@ function htmlToInline(html: string): string {
|
|
|
929
954
|
// Strip remaining tags
|
|
930
955
|
result = result.replace(RE_STRIP_TAGS, '');
|
|
931
956
|
|
|
932
|
-
return unescapeHtml(result);
|
|
957
|
+
return restoreLeadingSpaces(unescapeHtml(result));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function preserveLeadingSpaces(html: string): string {
|
|
961
|
+
return html.replace(/^ +/, (spaces) => ' '.repeat(spaces.length));
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function restoreLeadingSpaces(text: string): string {
|
|
965
|
+
return text.replace(/(^|\n)(\u00a0+)/g, (_match, prefix: string, spaces: string) => {
|
|
966
|
+
return prefix + ' '.repeat(spaces.length);
|
|
967
|
+
});
|
|
933
968
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
|
|
2
|
+
|
|
3
|
+
const VALID_TRANSFORM_IDS = new Set(getTransformStyleSummaries().map((summary) => summary.id));
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a transform id read from document frontmatter.
|
|
7
|
+
*
|
|
8
|
+
* `dataDriven` was written by older Squisq versions. It remains accepted as a
|
|
9
|
+
* wire-format compatibility value, but every editor surface exposes and writes
|
|
10
|
+
* only the canonical `data-driven` id.
|
|
11
|
+
*/
|
|
12
|
+
export function resolvePersistedTransformStyleId(value: unknown): string | null {
|
|
13
|
+
if (typeof value !== 'string') return null;
|
|
14
|
+
const normalized = value.trim().toLowerCase().replace(/\s+/g, '-');
|
|
15
|
+
if (normalized === 'datadriven') return 'data-driven';
|
|
16
|
+
return VALID_TRANSFORM_IDS.has(normalized) ? normalized : null;
|
|
17
|
+
}
|