@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.
Files changed (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +38 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +312 -6
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. 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
+ }
@@ -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
- outputBlocks.push(`<${tag}${attr}>${listItems.join('')}</${tag}>`);
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
- outputBlocks.push(`<p>${inlineToHtml(tl)}</p>`);
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
- outputBlocks.push(`<table><thead><tr>${thHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>`);
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
- outputBlocks.push(
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
- attrs += ` data-template="${escapeHtml(tokens[0] ?? '')}"`;
237
- const params = tokens.slice(1).filter((t) => t.includes('='));
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
- outputBlocks.push(`<h${level}${attrs}>${inlineToHtml(text)}</h${level}>`);
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
- outputBlocks.push('<hr>');
278
+ pushBlock('<hr>');
254
279
  continue;
255
280
  }
256
281
 
257
282
  // Blockquote
258
283
  if (line.startsWith('> ')) {
259
284
  flushList();
260
- outputBlocks.push(`<blockquote><p>${inlineToHtml(line.slice(2))}</p></blockquote>`);
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
- outputBlocks.push(`<img alt="${alt}" src="${src}">`);
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
- outputBlocks.push(trimmed);
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
- outputBlocks.push(trimmed);
376
+ pushBlock(trimmed);
352
377
  continue;
353
378
  }
354
379
 
355
380
  // Regular paragraph
356
381
  flushList();
357
- outputBlocks.push(`<p>${inlineToHtml(line)}</p>`);
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
- outputBlocks.push(
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
- let text = htmlToInline(headingMatch[3]);
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 `{[…]}` template
396
- // annotation (matches blockToMdast in core/markdown/convert.ts).
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
- if (tmplMatch) {
400
- // Strip an accidental trailing copy of the template label that an
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
- const codeMatch = remaining.match(
429
- /^<pre><code(?:\s+class="language-([^"]*)")?>(.*?)<\/code><\/pre>/s,
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
- // Clean up trailing blank lines
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(/&lt;/g, '<')
794
818
  .replace(/&gt;/g, '>')
795
819
  .replace(/&quot;/g, '"')
820
+ .replace(/&nbsp;|&#160;|&#xA0;/gi, '\u00a0')
796
821
  .replace(/&amp;/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) => '&nbsp;'.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
+ }