@bendyline/squisq-editor-react 1.6.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  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 +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -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 +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  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 +15 -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__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -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 +382 -5
  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__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,381 @@
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
+ AsciiTimelineTrack,
16
+ } from '@bendyline/squisq/doc';
17
+
18
+ const STRUCTURAL_MARKERS = /[●○◉◆◇•]+/gu;
19
+ const LEADING_RAILS = /^[─━═╌╍┄┅┈┉-]+/u;
20
+ const COORDINATE_PRECISION = 1_000;
21
+ const COLUMN_EPSILON = 1 / COORDINATE_PRECISION / 2;
22
+
23
+ export interface TimelineEventPatch {
24
+ label?: string;
25
+ /** `null` clears the optional description. */
26
+ description?: string | null;
27
+ side?: AsciiTimelineSide;
28
+ /** `null` restores the label side/default placement. */
29
+ descriptionSide?: AsciiTimelineSide | null;
30
+ callout?: boolean;
31
+ marker?: AsciiTimelineMarker;
32
+ /** Normalized position on the global timeline scale. */
33
+ position?: number;
34
+ }
35
+
36
+ export interface AddTimelineEventOptions {
37
+ id?: string;
38
+ label?: string;
39
+ description?: string;
40
+ side?: AsciiTimelineSide;
41
+ descriptionSide?: AsciiTimelineSide;
42
+ callout?: boolean;
43
+ marker?: AsciiTimelineMarker;
44
+ }
45
+
46
+ export interface AddTimelineEventResult {
47
+ timeline: AsciiTimeline;
48
+ eventId: string;
49
+ }
50
+
51
+ export interface AddTimelineTrackOptions {
52
+ id?: string;
53
+ label?: string;
54
+ eventId?: string;
55
+ eventLabel?: string;
56
+ }
57
+
58
+ export interface AddTimelineTrackResult {
59
+ timeline: AsciiTimeline;
60
+ trackId: string;
61
+ eventId: string;
62
+ }
63
+
64
+ /**
65
+ * Normalize editable one-line prose exactly as the canonical core renderer
66
+ * does. This prevents the verified command path from accepting a value that
67
+ * is silently reinterpreted as timeline syntax on its first render.
68
+ */
69
+ export function sanitizeTimelineText(value: string): string {
70
+ const normalized = value
71
+ .normalize('NFC')
72
+ .replace(/[\r\n\t]+/g, ' ')
73
+ .replace(STRUCTURAL_MARKERS, '·')
74
+ .replace(/\*/g, '∗')
75
+ .replace(/::/g, '∶')
76
+ .replace(/\s+/g, ' ')
77
+ .trim();
78
+
79
+ return normalized.replace(LEADING_RAILS, (run) =>
80
+ Array.from(run, (character) => (character === '-' ? '−' : '–')).join(''),
81
+ );
82
+ }
83
+
84
+ /** Return a globally unique, renderer-safe event id. */
85
+ export function nextTimelineEventId(timeline: AsciiTimeline, base = 'event'): string {
86
+ const used = new Set(timeline.tracks.flatMap((track) => track.events.map((event) => event.id)));
87
+ const safeBase = safeId(base, 'event');
88
+ if (!used.has(safeBase)) return safeBase;
89
+ let suffix = 2;
90
+ while (used.has(`${safeBase}-${suffix}`)) suffix++;
91
+ return `${safeBase}-${suffix}`;
92
+ }
93
+
94
+ /** Return a renderer-safe id that is unique among timeline tracks. */
95
+ export function nextTimelineTrackId(timeline: AsciiTimeline, base = 'track'): string {
96
+ const used = new Set(timeline.tracks.map((track) => track.id));
97
+ const safeBase = safeId(base, 'track');
98
+ if (!used.has(safeBase)) return safeBase;
99
+ let suffix = 2;
100
+ while (used.has(`${safeBase}-${suffix}`)) suffix++;
101
+ return `${safeBase}-${suffix}`;
102
+ }
103
+
104
+ /**
105
+ * Add a representable track with one starter point. Empty tracks are not part
106
+ * of the authored ASCII grammar, so line creation and its first point are one
107
+ * atomic operation/undo step.
108
+ */
109
+ export function addTimelineTrackOp(
110
+ timeline: AsciiTimeline,
111
+ options: AddTimelineTrackOptions = {},
112
+ ): AddTimelineTrackResult {
113
+ const label = sanitizeTimelineText(options.label ?? '') || 'New line';
114
+ const eventLabel = sanitizeTimelineText(options.eventLabel ?? '') || 'New event';
115
+ const trackId = nextTimelineTrackId(timeline, options.id ?? label);
116
+ const eventId = nextTimelineEventId(timeline, options.eventId ?? eventLabel);
117
+ const { start, span } = globalBounds(timeline);
118
+ const column = Math.round((start + span / 2) * COORDINATE_PRECISION) / COORDINATE_PRECISION;
119
+ const row = timeline.tracks.reduce((maximum, track) => Math.max(maximum, track.row), -1) + 1;
120
+ const track: AsciiTimelineTrack = {
121
+ id: trackId,
122
+ label,
123
+ row,
124
+ startColumn: start,
125
+ endColumn: start + span,
126
+ events: [
127
+ {
128
+ id: eventId,
129
+ label: eventLabel,
130
+ column,
131
+ side: 'above',
132
+ marker: 'filled',
133
+ },
134
+ ],
135
+ };
136
+
137
+ return {
138
+ timeline: {
139
+ ...timeline,
140
+ tracks: [...timeline.tracks, track],
141
+ width: Math.max(timeline.width, Math.ceil(track.endColumn) + 1),
142
+ height: Math.max(timeline.height, row + 1),
143
+ },
144
+ trackId,
145
+ eventId,
146
+ };
147
+ }
148
+
149
+ /** Rename a track without changing its stable id, points, or branches. */
150
+ export function updateTimelineTrackOp(
151
+ timeline: AsciiTimeline,
152
+ trackId: string,
153
+ label: string,
154
+ ): AsciiTimeline {
155
+ const track = timeline.tracks.find((candidate) => candidate.id === trackId);
156
+ const nextLabel = sanitizeTimelineText(label);
157
+ if (!track || !nextLabel || track.label === nextLabel) return timeline;
158
+ return {
159
+ ...timeline,
160
+ tracks: timeline.tracks.map((candidate) =>
161
+ candidate.id === trackId ? { ...candidate, label: nextLabel } : candidate,
162
+ ),
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Remove a whole track, including branches incident to any of its points.
168
+ * Keep the last track so the source fence remains representable and mounted.
169
+ */
170
+ export function removeTimelineTrackOp(timeline: AsciiTimeline, trackId: string): AsciiTimeline {
171
+ if (timeline.tracks.length <= 1) return timeline;
172
+ const track = timeline.tracks.find((candidate) => candidate.id === trackId);
173
+ if (!track) return timeline;
174
+ const removedEventIds = new Set(track.events.map((event) => event.id));
175
+ return {
176
+ ...timeline,
177
+ tracks: timeline.tracks.filter((candidate) => candidate.id !== trackId),
178
+ links: timeline.links.filter(
179
+ (link) => !removedEventIds.has(link.source) && !removedEventIds.has(link.target),
180
+ ),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Add a visible point to `trackId` at a normalized global rail position.
186
+ * Returns the stable id so the canvas can select/focus the new point after
187
+ * the fence rewrite.
188
+ */
189
+ export function addTimelineEventOp(
190
+ timeline: AsciiTimeline,
191
+ trackId: string,
192
+ position: number,
193
+ options: AddTimelineEventOptions = {},
194
+ ): AddTimelineEventResult | null {
195
+ if (!Number.isFinite(position)) return null;
196
+ const track = timeline.tracks.find((candidate) => candidate.id === trackId);
197
+ if (!track) return null;
198
+
199
+ const column = columnAtPosition(timeline, position);
200
+ // Two coincident markers cannot be independently hit on the canvas. A
201
+ // marker click should select the existing point rather than adding atop it.
202
+ if (track.events.some((event) => Math.abs(event.column - column) <= COLUMN_EPSILON)) return null;
203
+
204
+ const label = sanitizeTimelineText(options.label ?? '') || 'New event';
205
+ const description = sanitizeTimelineText(options.description ?? '');
206
+ const eventId = nextTimelineEventId(timeline, options.id ?? label);
207
+ const insertionIndex = track.events.filter((event) => event.column < column).length;
208
+ const side = options.side ?? (insertionIndex % 2 === 0 ? 'above' : 'below');
209
+ const event: AsciiTimelineEvent = {
210
+ id: eventId,
211
+ label,
212
+ ...(description ? { description } : {}),
213
+ column,
214
+ side,
215
+ ...(description && options.descriptionSide ? { descriptionSide: options.descriptionSide } : {}),
216
+ ...(options.callout === false ? { callout: false } : {}),
217
+ marker: options.marker ?? 'filled',
218
+ };
219
+
220
+ const events = [...track.events, event].sort(
221
+ (left, right) => left.column - right.column || left.id.localeCompare(right.id),
222
+ );
223
+ const tracks = timeline.tracks.map((candidate) =>
224
+ candidate.id === trackId ? { ...candidate, events } : candidate,
225
+ );
226
+ // ASCII art has no distinct diamond glyph. Promote the authored vocabulary
227
+ // when the inspector explicitly requests one rather than silently folding
228
+ // the user's choice back to a filled point in the renderer.
229
+ const style = options.marker === 'diamond' ? 'unicode' : timeline.style;
230
+ return { timeline: { ...timeline, tracks, style }, eventId };
231
+ }
232
+
233
+ /** Update one point without changing its stable id or branch endpoints. */
234
+ export function updateTimelineEventOp(
235
+ timeline: AsciiTimeline,
236
+ eventId: string,
237
+ patch: TimelineEventPatch,
238
+ ): AsciiTimeline {
239
+ const location = findEvent(timeline, eventId);
240
+ if (!location || Object.keys(patch).length === 0) return timeline;
241
+
242
+ const hasLabel = Object.prototype.hasOwnProperty.call(patch, 'label');
243
+ const hasDescription = Object.prototype.hasOwnProperty.call(patch, 'description');
244
+ const hasDescriptionSide = Object.prototype.hasOwnProperty.call(patch, 'descriptionSide');
245
+ const hasPosition = Object.prototype.hasOwnProperty.call(patch, 'position');
246
+ const label = hasLabel ? sanitizeTimelineText(patch.label ?? '') : location.event.label;
247
+ if (!label) return timeline;
248
+
249
+ let description = hasDescription
250
+ ? sanitizeTimelineText(patch.description ?? '')
251
+ : location.event.description;
252
+ if (!description) description = undefined;
253
+
254
+ let column = location.event.column;
255
+ if (hasPosition) {
256
+ if (!Number.isFinite(patch.position)) return timeline;
257
+ column = columnAtPosition(timeline, patch.position as number);
258
+ const collision = location.track.events.some(
259
+ (event) => event.id !== eventId && Math.abs(event.column - column) <= COLUMN_EPSILON,
260
+ );
261
+ if (collision) return timeline;
262
+ }
263
+
264
+ const editedText = hasLabel || hasDescription;
265
+ const callout =
266
+ patch.callout !== undefined
267
+ ? patch.callout
268
+ : editedText && location.event.callout === false
269
+ ? true
270
+ : location.event.callout;
271
+ const descriptionSide = !description
272
+ ? undefined
273
+ : hasDescriptionSide
274
+ ? (patch.descriptionSide ?? undefined)
275
+ : location.event.descriptionSide;
276
+
277
+ const nextEvent: AsciiTimelineEvent = {
278
+ ...location.event,
279
+ label,
280
+ column,
281
+ ...(description ? { description } : {}),
282
+ ...(patch.side ? { side: patch.side } : {}),
283
+ ...(descriptionSide ? { descriptionSide } : {}),
284
+ ...(callout !== undefined ? { callout } : {}),
285
+ ...(patch.marker ? { marker: patch.marker } : {}),
286
+ };
287
+ if (!description) delete nextEvent.description;
288
+ if (!descriptionSide) delete nextEvent.descriptionSide;
289
+
290
+ if (eventsEqual(location.event, nextEvent)) return timeline;
291
+ const events = location.track.events
292
+ .map((event) => (event.id === eventId ? nextEvent : event))
293
+ .sort((left, right) => left.column - right.column || left.id.localeCompare(right.id));
294
+ // A point authored exactly on a track boundary anchors that edge. Let the
295
+ // old scale remain stable throughout a drag preview, then move/reassign the
296
+ // boundary in the single drop transaction. Crossing the next point hands
297
+ // the edge to that point; moving any point outside a shorter track expands
298
+ // the track so every marker remains inside its authored bounds.
299
+ const movedPosition = hasPosition && nextEvent.column !== location.event.column;
300
+ const anchoredStart =
301
+ movedPosition && Math.abs(location.event.column - location.track.startColumn) <= COLUMN_EPSILON;
302
+ const anchoredEnd =
303
+ movedPosition && Math.abs(location.event.column - location.track.endColumn) <= COLUMN_EPSILON;
304
+ const eventStart = Math.min(...events.map((event) => event.column));
305
+ const eventEnd = Math.max(...events.map((event) => event.column));
306
+ const startColumn = anchoredStart ? eventStart : Math.min(location.track.startColumn, eventStart);
307
+ const endColumn = anchoredEnd ? eventEnd : Math.max(location.track.endColumn, eventEnd);
308
+ const tracks = timeline.tracks.map((track) =>
309
+ track.id === location.track.id ? { ...track, startColumn, endColumn, events } : track,
310
+ );
311
+ const style = patch.marker === 'diamond' ? 'unicode' : timeline.style;
312
+ return { ...timeline, tracks, style };
313
+ }
314
+
315
+ /**
316
+ * Remove a point and every incident branch. Empty tracks are removed because
317
+ * the canonical renderer cannot represent them. The last point in the whole
318
+ * timeline is retained so an edit cannot erase its own source fence/widget.
319
+ */
320
+ export function removeTimelineEventOp(timeline: AsciiTimeline, eventId: string): AsciiTimeline {
321
+ const location = findEvent(timeline, eventId);
322
+ if (!location) return timeline;
323
+ const eventCount = timeline.tracks.reduce((sum, track) => sum + track.events.length, 0);
324
+ if (eventCount <= 1) return timeline;
325
+
326
+ const tracks = timeline.tracks
327
+ .map((track) =>
328
+ track.id === location.track.id
329
+ ? { ...track, events: track.events.filter((event) => event.id !== eventId) }
330
+ : track,
331
+ )
332
+ .filter((track) => track.events.length > 0);
333
+ const links = timeline.links.filter((link) => link.source !== eventId && link.target !== eventId);
334
+ return { ...timeline, tracks, links };
335
+ }
336
+
337
+ function globalBounds(timeline: AsciiTimeline): { start: number; span: number } {
338
+ if (timeline.tracks.length === 0) return { start: 0, span: 1 };
339
+ const start = Math.min(...timeline.tracks.map((track) => track.startColumn));
340
+ const end = Math.max(...timeline.tracks.map((track) => track.endColumn));
341
+ return { start, span: Math.max(1, end - start) };
342
+ }
343
+
344
+ function columnAtPosition(timeline: AsciiTimeline, position: number): number {
345
+ const { start, span } = globalBounds(timeline);
346
+ const clamped = Math.max(0, Math.min(1, position));
347
+ return Math.round((start + clamped * span) * COORDINATE_PRECISION) / COORDINATE_PRECISION;
348
+ }
349
+
350
+ function safeId(value: string, fallback: string): string {
351
+ return (
352
+ sanitizeTimelineText(value)
353
+ .toLowerCase()
354
+ .replace(/[^a-z0-9_.~-]+/g, '-')
355
+ .replace(/^-+|-+$/g, '') || fallback
356
+ );
357
+ }
358
+
359
+ function findEvent(
360
+ timeline: AsciiTimeline,
361
+ eventId: string,
362
+ ): { track: AsciiTimeline['tracks'][number]; event: AsciiTimelineEvent } | null {
363
+ for (const track of timeline.tracks) {
364
+ const event = track.events.find((candidate) => candidate.id === eventId);
365
+ if (event) return { track, event };
366
+ }
367
+ return null;
368
+ }
369
+
370
+ function eventsEqual(left: AsciiTimelineEvent, right: AsciiTimelineEvent): boolean {
371
+ return (
372
+ left.id === right.id &&
373
+ left.label === right.label &&
374
+ left.description === right.description &&
375
+ left.column === right.column &&
376
+ left.side === right.side &&
377
+ left.descriptionSide === right.descriptionSide &&
378
+ left.callout === right.callout &&
379
+ left.marker === right.marker
380
+ );
381
+ }
@@ -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,
@@ -251,6 +250,11 @@ export function markdownToTiptap(markdown: string): string {
251
250
  // tokens are stored raw — quotes included — so tiptapToMarkdown
252
251
  // can re-join them into the annotation verbatim.
253
252
  const tokens = tokenizeAttrTokens(templateInner);
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
+ }
254
258
  const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
255
259
  if (!firstIsParam && tokens[0]) {
256
260
  attrs += ` data-template="${escapeHtml(tokens[0])}"`;
@@ -278,7 +282,14 @@ export function markdownToTiptap(markdown: string): string {
278
282
  // Blockquote
279
283
  if (line.startsWith('> ')) {
280
284
  flushList();
281
- pushBlock(`<blockquote><p>${inlineToHtml(line.slice(2))}</p></blockquote>`);
285
+ const quoteLines = [line.slice(2)];
286
+ while (i + 1 < lines.length && lines[i + 1].startsWith('> ')) {
287
+ i++;
288
+ quoteLines.push(lines[i].slice(2));
289
+ }
290
+ pushBlock(
291
+ `<blockquote>${quoteLines.map((quoteLine) => `<p>${inlineToHtml(quoteLine)}</p>`).join('')}</blockquote>`,
292
+ );
282
293
  continue;
283
294
  }
284
295
 
@@ -409,7 +420,16 @@ export function tiptapToMarkdown(html: string): string {
409
420
  if (headingMatch) {
410
421
  const level = parseInt(headingMatch[1], 10);
411
422
  const attrs = headingMatch[2];
412
- let text = htmlToInline(headingMatch[3]);
423
+ const headingHtml = headingMatch[3];
424
+ // Template/property badges are editor chrome, not heading content. Strip
425
+ // them structurally before converting the inline HTML. Older builds
426
+ // briefly rendered the template label as real badge text; using the
427
+ // markup boundary preserves that cleanup without mistaking a legitimate
428
+ // heading suffix (for example "A Famous Quote") for the badge label.
429
+ const chromeStart = headingHtml.search(
430
+ /<span\b[^>]*\bclass="[^"]*\bsquisq-(?:template|props)-badge\b[^"]*"[^>]*>/i,
431
+ );
432
+ let text = htmlToInline(chromeStart >= 0 ? headingHtml.slice(0, chromeStart) : headingHtml);
413
433
 
414
434
  // Re-inject heading annotations from data attributes. Canonical
415
435
  // emit order: Pandoc `{#…}` first, then squisq `{[…]}` annotation
@@ -417,15 +437,7 @@ export function tiptapToMarkdown(html: string): string {
417
437
  const blockAttrsMatch = attrs.match(/data-block-attrs="([^"]*)"/);
418
438
  const tmplMatch = attrs.match(/data-template="([^"]+)"/);
419
439
  const paramsMatch = attrs.match(/data-template-params="([^"]+)"/);
420
- if (tmplMatch) {
421
- // Strip an accidental trailing copy of the template label that an
422
- // earlier broken build briefly rendered as real text inside the
423
- // badge. Existing documents self-heal on save.
424
- const label = templateLabel(tmplMatch[1]);
425
- if (label && text.endsWith(label)) {
426
- text = text.slice(0, -label.length).trimEnd();
427
- }
428
- }
440
+ const hasEmptyTemplateAnnotation = /\sdata-template-empty(?:="[^"]*")?/.test(attrs);
429
441
  if (blockAttrsMatch) {
430
442
  const inner = unescapeHtml(blockAttrsMatch[1]);
431
443
  text += ` {${inner}}`;
@@ -436,6 +448,8 @@ export function tiptapToMarkdown(html: string): string {
436
448
  annotation += (annotation ? ' ' : '') + unescapeHtml(paramsMatch[1]);
437
449
  }
438
450
  text += ` {[${annotation}]}`;
451
+ } else if (hasEmptyTemplateAnnotation) {
452
+ text += ' {[]}';
439
453
  }
440
454
 
441
455
  lines.push('#'.repeat(level) + ' ' + text);
@@ -444,12 +458,13 @@ export function tiptapToMarkdown(html: string): string {
444
458
  continue;
445
459
  }
446
460
 
447
- // Code blocks
448
- const codeMatch = remaining.match(
449
- /^<pre><code(?:\s+class="language-([^"]*)")?>(.*?)<\/code><\/pre>/s,
450
- );
461
+ // Code blocks — tolerate attributes on <pre> and <code>: real
462
+ // editor.getHTML() output carries class="squisq-code-block" on the
463
+ // <pre> (StarterKit codeBlock HTMLAttributes), which a bare
464
+ // `<pre><code` anchor never matches, silently dropping the fence.
465
+ const codeMatch = remaining.match(/^<pre\b[^>]*><code\b([^>]*)>(.*?)<\/code><\/pre>/s);
451
466
  if (codeMatch) {
452
- const lang = codeMatch[1] || '';
467
+ const lang = /\bclass="language-([^"]*)"/.exec(codeMatch[1] ?? '')?.[1] ?? '';
453
468
  const code = unescapeHtml(codeMatch[2]);
454
469
  lines.push('```' + lang);
455
470
  lines.push(code);
@@ -462,10 +477,21 @@ export function tiptapToMarkdown(html: string): string {
462
477
  // Blockquote
463
478
  const bqMatch = remaining.match(/^<blockquote>(.*?)<\/blockquote>/s);
464
479
  if (bqMatch) {
465
- const inner = htmlToInline(bqMatch[1].replace(/<\/?p>/g, ''));
466
- lines.push('> ' + inner);
467
- lines.push('');
468
- remaining = remaining.slice(bqMatch[0].length);
480
+ const paragraphs = bqMatch[1]
481
+ .split(/<\/p>\s*<p[^>]*>/i)
482
+ .map((paragraph) => paragraph.replace(/^<p[^>]*>/i, '').replace(/<\/p>\s*$/i, ''));
483
+ for (const paragraph of paragraphs) {
484
+ for (const quoteLine of htmlToInline(paragraph).split('\n')) {
485
+ lines.push('> ' + quoteLine);
486
+ }
487
+ }
488
+ const next = remaining.slice(bqMatch[0].length);
489
+ // Older bridge output and direct Tiptap edits can still leave adjacent
490
+ // blockquote nodes. Keep those nodes adjacent when serializing;
491
+ // inserting the normal block separator here turns one quote into two
492
+ // paragraphs every time the document passes through WYSIWYG mode.
493
+ if (!/^\s*<blockquote>/.test(next)) lines.push('');
494
+ remaining = next;
469
495
  continue;
470
496
  }
471
497
 
@@ -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
+ }