@bendyline/squisq-editor-react 1.6.2 → 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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  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 +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  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 +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,702 @@
1
+ /**
2
+ * Accessible WYSIWYG editor for one authored ASCII timeline fence.
3
+ *
4
+ * The canvas is deliberately DOM/SVG rather than a freeform diagram scene:
5
+ * tracks share one normalized horizontal scale, markers are real buttons,
6
+ * and the selected event is edited through ordinary form controls.
7
+ */
8
+
9
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
+ import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from 'react';
11
+ import type { Editor } from '@tiptap/react';
12
+ import {
13
+ asciiTimelineToTemplateData,
14
+ type AsciiTimelineEvent,
15
+ type AsciiTimelineMarker,
16
+ type AsciiTimelineSide,
17
+ } from '@bendyline/squisq/doc';
18
+ import { Icon } from '../Icon';
19
+ import { useTimelineData } from './timelineData';
20
+ import {
21
+ applyTimelineCommand,
22
+ isTimelineSourceSafeForSemanticEdit,
23
+ type TimelineCommand,
24
+ type TimelineCommandResult,
25
+ } from './timelineCommands';
26
+
27
+ export interface TimelineEditorWidgetProps {
28
+ editor: Editor;
29
+ blockId: string;
30
+ }
31
+
32
+ interface SelectedEvent {
33
+ event: AsciiTimelineEvent;
34
+ trackId: string;
35
+ trackLabel: string;
36
+ }
37
+
38
+ interface PositionedEvent {
39
+ event: AsciiTimelineEvent;
40
+ trackId: string;
41
+ trackIndex: number;
42
+ position: number;
43
+ }
44
+
45
+ interface DraggedEvent {
46
+ eventId: string;
47
+ pointerId: number;
48
+ startClientX: number;
49
+ startPosition: number;
50
+ position: number;
51
+ moved: boolean;
52
+ }
53
+
54
+ const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
55
+
56
+ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetProps) {
57
+ const view = useTimelineData(editor, blockId);
58
+ const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
59
+ const [hover, setHover] = useState<{ trackId: string; position: number } | null>(null);
60
+ const [dragged, setDragged] = useState<DraggedEvent | null>(null);
61
+ const suppressClickEventId = useRef<string | null>(null);
62
+ const [announcement, setAnnouncement] = useState('');
63
+
64
+ const dispatch = useCallback(
65
+ (command: TimelineCommand): TimelineCommandResult => {
66
+ const result = applyTimelineCommand(editor, blockId, command);
67
+ if (result.reason === 'read-only') setAnnouncement('Timeline is read only.');
68
+ if (result.reason === 'unsafe-source') {
69
+ setAnnouncement('Visual editing is paused until the timeline source is repaired.');
70
+ }
71
+ return result;
72
+ },
73
+ [editor, blockId],
74
+ );
75
+
76
+ const sourceText = view?.text;
77
+ const sourceTimeline = view?.timeline;
78
+ const sourceSafe = useMemo(
79
+ () =>
80
+ sourceText && sourceTimeline
81
+ ? isTimelineSourceSafeForSemanticEdit(sourceText, sourceTimeline)
82
+ : false,
83
+ // `useTimelineData` publishes on every editor transaction so read-only
84
+ // changes are observed. Unrelated transactions retain both primitive/model
85
+ // identities; depending on those keeps the linear safety audit out of the
86
+ // editor's cursor/selection hot path.
87
+ [sourceText, sourceTimeline],
88
+ );
89
+
90
+ const positioned = useMemo(() => {
91
+ if (!view) return [];
92
+ const normalized = asciiTimelineToTemplateData(view.timeline).tracks;
93
+ return view.timeline.tracks.flatMap((track, trackIndex) => {
94
+ const templateTrack = normalized[trackIndex];
95
+ return track.events.map(
96
+ (event, eventIndex): PositionedEvent => ({
97
+ event,
98
+ trackId: track.id,
99
+ trackIndex,
100
+ position: templateTrack?.events[eventIndex]?.position ?? 0,
101
+ }),
102
+ );
103
+ });
104
+ }, [view]);
105
+
106
+ const displayedPositioned = useMemo(
107
+ () =>
108
+ dragged
109
+ ? positioned.map((point) =>
110
+ point.event.id === dragged.eventId ? { ...point, position: dragged.position } : point,
111
+ )
112
+ : positioned,
113
+ [dragged, positioned],
114
+ );
115
+
116
+ const positionById = useMemo(
117
+ () => new Map(displayedPositioned.map((point) => [point.event.id, point.position])),
118
+ [displayedPositioned],
119
+ );
120
+
121
+ const selected = useMemo<SelectedEvent | null>(() => {
122
+ if (!view || !selectedEventId) return null;
123
+ for (const track of view.timeline.tracks) {
124
+ const event = track.events.find((candidate) => candidate.id === selectedEventId);
125
+ if (event) {
126
+ return {
127
+ event,
128
+ trackId: track.id,
129
+ trackLabel: track.label || `Track ${view.timeline.tracks.indexOf(track) + 1}`,
130
+ };
131
+ }
132
+ }
133
+ return null;
134
+ }, [selectedEventId, view]);
135
+
136
+ useEffect(() => {
137
+ if (!view) return;
138
+ const ids = new Set(
139
+ view.timeline.tracks.flatMap((track) => track.events.map((event) => event.id)),
140
+ );
141
+ if (selectedEventId && ids.has(selectedEventId)) return;
142
+ setSelectedEventId(view.timeline.tracks[0]?.events[0]?.id ?? null);
143
+ }, [selectedEventId, view]);
144
+
145
+ if (!view) return null;
146
+
147
+ const editorEditable = editor.isEditable;
148
+ const editable = editorEditable && sourceSafe;
149
+ const totalEvents = positioned.length;
150
+ const addEvent = (trackId: string, position: number) => {
151
+ if (!editable) return;
152
+ const result = dispatch({ kind: 'addEvent', trackId, position: clamp01(position) });
153
+ if (result.applied && result.eventId) {
154
+ setSelectedEventId(result.eventId);
155
+ setAnnouncement('New timeline point added. Edit its text below.');
156
+ }
157
+ };
158
+
159
+ const selectEvent = (event: AsciiTimelineEvent, trackLabel: string) => {
160
+ setSelectedEventId(event.id);
161
+ setAnnouncement(`${event.label || 'Timeline point'} selected on ${trackLabel}.`);
162
+ };
163
+
164
+ const dragPosition = (event: ReactPointerEvent<HTMLButtonElement>): number | null => {
165
+ const rail = event.currentTarget.closest<HTMLElement>('.squisq-ascii-timeline-rail');
166
+ if (!rail) return null;
167
+ const rect = rail.getBoundingClientRect();
168
+ if (rect.width <= 0 || !Number.isFinite(event.clientX)) return null;
169
+ return clamp01((event.clientX - rect.left) / rect.width);
170
+ };
171
+
172
+ return (
173
+ <section className="squisq-ascii-timeline-editor" aria-label="Timeline editor">
174
+ <header className="squisq-ascii-timeline-header">
175
+ <span>
176
+ <Icon icon="fa-solid fa-timeline" /> Timeline
177
+ </span>
178
+ {editable ? (
179
+ <small>Click the line to add a point · Drag dots to move</small>
180
+ ) : editorEditable ? (
181
+ <small>Source repair needed</small>
182
+ ) : (
183
+ <small>Read only</small>
184
+ )}
185
+ </header>
186
+
187
+ <div
188
+ className="squisq-ascii-timeline-canvas"
189
+ style={
190
+ { '--squisq-ascii-timeline-track-count': view.timeline.tracks.length } as CSSProperties
191
+ }
192
+ >
193
+ {view.timeline.links.length > 0 ? (
194
+ <svg
195
+ className="squisq-ascii-timeline-branches"
196
+ viewBox={`0 0 100 ${Math.max(1, view.timeline.tracks.length) * 96}`}
197
+ preserveAspectRatio="none"
198
+ aria-hidden="true"
199
+ >
200
+ {view.timeline.links.map((link, index) => {
201
+ const source = displayedPositioned.find((point) => point.event.id === link.source);
202
+ const target = displayedPositioned.find((point) => point.event.id === link.target);
203
+ if (!source || !target) return null;
204
+ const sx = source.position * 100;
205
+ const tx = target.position * 100;
206
+ const sy = source.trackIndex * 96 + 48;
207
+ const ty = target.trackIndex * 96 + 48;
208
+ const sameTrack = source.trackIndex === target.trackIndex;
209
+ const controlY = sameTrack
210
+ ? sy + (source.event.side === 'below' ? -30 : 30)
211
+ : (sy + ty) / 2;
212
+ return (
213
+ <path
214
+ key={`${link.source}-${link.target}-${index}`}
215
+ d={`M ${sx} ${sy} C ${sx} ${controlY}, ${tx} ${controlY}, ${tx} ${ty}`}
216
+ vectorEffect="non-scaling-stroke"
217
+ />
218
+ );
219
+ })}
220
+ </svg>
221
+ ) : null}
222
+
223
+ {view.timeline.tracks.map((track, trackIndex) => {
224
+ const trackLabel = track.label || `Track ${trackIndex + 1}`;
225
+ const events = track.events
226
+ .map((event) => ({ event, position: positionById.get(event.id) ?? 0 }))
227
+ .sort((a, b) => a.position - b.position);
228
+ const eventPositions = events.map((entry) => entry.position);
229
+ const gaps = insertionGaps(eventPositions);
230
+ return (
231
+ <div className="squisq-ascii-timeline-track" key={track.id}>
232
+ <div className="squisq-ascii-timeline-track-label" title={trackLabel}>
233
+ <span>{trackLabel}</span>
234
+ {editable ? (
235
+ <button
236
+ type="button"
237
+ className="squisq-ascii-timeline-track-add"
238
+ aria-label={`Add point to ${trackLabel} timeline`}
239
+ title="Add point in the largest available gap"
240
+ onClick={() => addEvent(track.id, largestGap(eventPositions))}
241
+ >
242
+ +
243
+ </button>
244
+ ) : null}
245
+ </div>
246
+ <div
247
+ className={`squisq-ascii-timeline-rail${editable ? '' : ' squisq-ascii-timeline-rail--readonly'}`}
248
+ role="group"
249
+ aria-label={`${trackLabel} timeline rail`}
250
+ title={editable ? 'Click anywhere on the line to add a point' : undefined}
251
+ onPointerMove={(event) => {
252
+ if (!editable || dragged) return;
253
+ const rect = event.currentTarget.getBoundingClientRect();
254
+ if (rect.width <= 0) return;
255
+ setHover({
256
+ trackId: track.id,
257
+ position: clamp01((event.clientX - rect.left) / rect.width),
258
+ });
259
+ }}
260
+ onPointerLeave={() =>
261
+ setHover((current) => (current?.trackId === track.id ? null : current))
262
+ }
263
+ onClick={(event) => {
264
+ if (!editable || event.target !== event.currentTarget) return;
265
+ const rect = event.currentTarget.getBoundingClientRect();
266
+ if (rect.width <= 0) return;
267
+ addEvent(track.id, (event.clientX - rect.left) / rect.width);
268
+ }}
269
+ >
270
+ <span className="squisq-ascii-timeline-rail-line" aria-hidden="true" />
271
+ {hover?.trackId === track.id && !dragged ? (
272
+ <span
273
+ className="squisq-ascii-timeline-add-ghost"
274
+ style={{ left: `${hover.position * 100}%` }}
275
+ aria-hidden="true"
276
+ >
277
+ +
278
+ </span>
279
+ ) : null}
280
+
281
+ {editable && !dragged
282
+ ? gaps.map((position) => (
283
+ <button
284
+ type="button"
285
+ className="squisq-ascii-timeline-gap-add"
286
+ style={{ left: `${position * 100}%` }}
287
+ aria-label={`Add point to ${trackLabel} at ${Math.round(position * 100)} percent`}
288
+ title="Add point"
289
+ key={position}
290
+ onClick={(event) => {
291
+ event.stopPropagation();
292
+ addEvent(track.id, position);
293
+ }}
294
+ >
295
+ +
296
+ </button>
297
+ ))
298
+ : null}
299
+
300
+ {events.map(({ event, position }, eventIndex) => {
301
+ const selectedPoint = selectedEventId === event.id;
302
+ const draggedPoint = dragged?.eventId === event.id;
303
+ const side = event.side ?? (eventIndex % 2 === 0 ? 'above' : 'below');
304
+ const descriptionSide = event.descriptionSide ?? side;
305
+ return (
306
+ <span
307
+ className={`squisq-ascii-timeline-point squisq-ascii-timeline-point--${side}${draggedPoint ? ' squisq-ascii-timeline-point--dragging' : ''}`}
308
+ style={{ left: `${position * 100}%` }}
309
+ key={event.id}
310
+ >
311
+ {event.callout !== false ? (
312
+ <span
313
+ className={`squisq-ascii-timeline-callout squisq-ascii-timeline-callout--${side}`}
314
+ >
315
+ {event.label}
316
+ </span>
317
+ ) : null}
318
+ {event.callout !== false && event.description ? (
319
+ <span
320
+ className={`squisq-ascii-timeline-description squisq-ascii-timeline-description--${descriptionSide}`}
321
+ >
322
+ {event.description}
323
+ </span>
324
+ ) : null}
325
+ <button
326
+ type="button"
327
+ className={`squisq-ascii-timeline-marker squisq-ascii-timeline-marker--${event.marker ?? 'filled'}${selectedPoint ? ' squisq-ascii-timeline-marker--selected' : ''}`}
328
+ aria-label={`Edit ${event.label || 'timeline point'}, ${trackLabel}, ${Math.round(position * 100)} percent`}
329
+ aria-pressed={selectedPoint}
330
+ aria-roledescription={editable ? 'draggable timeline point' : undefined}
331
+ title={
332
+ editable
333
+ ? `${event.description || event.label || 'Timeline point'} · Drag to move`
334
+ : event.description || event.label
335
+ }
336
+ draggable={false}
337
+ onPointerDown={(pointerEvent) => {
338
+ if (
339
+ !editable ||
340
+ (pointerEvent.button !== undefined && pointerEvent.button !== 0)
341
+ ) {
342
+ return;
343
+ }
344
+ const point = dragPosition(pointerEvent);
345
+ if (point === null) return;
346
+ pointerEvent.stopPropagation();
347
+ setHover(null);
348
+ setSelectedEventId(event.id);
349
+ suppressClickEventId.current = null;
350
+ setDragged({
351
+ eventId: event.id,
352
+ pointerId: pointerEvent.pointerId,
353
+ startClientX: pointerEvent.clientX,
354
+ startPosition: position,
355
+ position,
356
+ moved: false,
357
+ });
358
+ pointerEvent.currentTarget.setPointerCapture?.(pointerEvent.pointerId);
359
+ }}
360
+ onPointerMove={(pointerEvent) => {
361
+ if (
362
+ !editable ||
363
+ !draggedPoint ||
364
+ dragged.pointerId !== pointerEvent.pointerId
365
+ ) {
366
+ return;
367
+ }
368
+ const point = dragPosition(pointerEvent);
369
+ if (point === null) return;
370
+ pointerEvent.preventDefault();
371
+ pointerEvent.stopPropagation();
372
+ setDragged((current) =>
373
+ current?.eventId === event.id &&
374
+ current.pointerId === pointerEvent.pointerId
375
+ ? {
376
+ ...current,
377
+ position: point,
378
+ moved:
379
+ current.moved ||
380
+ Math.abs(pointerEvent.clientX - current.startClientX) >= 3,
381
+ }
382
+ : current,
383
+ );
384
+ }}
385
+ onPointerUp={(pointerEvent) => {
386
+ if (!draggedPoint || dragged.pointerId !== pointerEvent.pointerId) {
387
+ return;
388
+ }
389
+ const point = dragPosition(pointerEvent);
390
+ const finalPosition = point ?? dragged.position;
391
+ const moved =
392
+ dragged.moved ||
393
+ Math.abs(pointerEvent.clientX - dragged.startClientX) >= 3;
394
+ pointerEvent.preventDefault();
395
+ pointerEvent.stopPropagation();
396
+ if (
397
+ pointerEvent.currentTarget.hasPointerCapture?.(pointerEvent.pointerId)
398
+ ) {
399
+ pointerEvent.currentTarget.releasePointerCapture(
400
+ pointerEvent.pointerId,
401
+ );
402
+ }
403
+ setDragged(null);
404
+ if (!moved) {
405
+ selectEvent(event, trackLabel);
406
+ return;
407
+ }
408
+ suppressClickEventId.current = event.id;
409
+ const result = dispatch({
410
+ kind: 'updateEvent',
411
+ eventId: event.id,
412
+ patch: { position: finalPosition },
413
+ });
414
+ if (result.applied) {
415
+ setAnnouncement(
416
+ `${event.label || 'Timeline point'} moved to ${Math.round(finalPosition * 100)} percent.`,
417
+ );
418
+ } else if (
419
+ !result.reason &&
420
+ Math.abs(finalPosition - dragged.startPosition) > 0.001
421
+ ) {
422
+ setAnnouncement(
423
+ 'Timeline points cannot overlap. Move the point elsewhere.',
424
+ );
425
+ }
426
+ }}
427
+ onPointerCancel={(pointerEvent) => {
428
+ if (!draggedPoint || dragged.pointerId !== pointerEvent.pointerId) {
429
+ return;
430
+ }
431
+ if (
432
+ pointerEvent.currentTarget.hasPointerCapture?.(pointerEvent.pointerId)
433
+ ) {
434
+ pointerEvent.currentTarget.releasePointerCapture(
435
+ pointerEvent.pointerId,
436
+ );
437
+ }
438
+ setDragged(null);
439
+ setAnnouncement('Timeline point move cancelled.');
440
+ }}
441
+ onClick={(clickEvent) => {
442
+ clickEvent.stopPropagation();
443
+ if (suppressClickEventId.current === event.id) {
444
+ suppressClickEventId.current = null;
445
+ return;
446
+ }
447
+ selectEvent(event, trackLabel);
448
+ }}
449
+ onKeyDown={(keyEvent) => navigateTrackPoints(keyEvent)}
450
+ />
451
+ </span>
452
+ );
453
+ })}
454
+ {track.endLabel ? (
455
+ <span className="squisq-ascii-timeline-end-label">{track.endLabel}</span>
456
+ ) : null}
457
+ </div>
458
+ </div>
459
+ );
460
+ })}
461
+ </div>
462
+
463
+ {view.timeline.links.length > 0 ? (
464
+ <ul className="squisq-ascii-timeline-link-list" aria-label="Timeline branches">
465
+ {view.timeline.links.map((link, index) => (
466
+ <li key={`${link.source}-${link.target}-${index}`}>
467
+ {link.source} → {link.target}
468
+ {link.label ? `: ${link.label}` : ''}
469
+ </li>
470
+ ))}
471
+ </ul>
472
+ ) : null}
473
+
474
+ {selected ? (
475
+ <EventInspector
476
+ key={`${selected.trackId}:${selected.event.id}`}
477
+ selected={selected}
478
+ editable={editable}
479
+ canDelete={totalEvents > 1}
480
+ dispatch={dispatch}
481
+ onDelete={() => {
482
+ const remaining = positioned.filter((point) => point.event.id !== selected.event.id);
483
+ const result = dispatch({ kind: 'removeEvent', eventId: selected.event.id });
484
+ if (result.applied) {
485
+ setSelectedEventId(remaining[0]?.event.id ?? null);
486
+ setAnnouncement(`${selected.event.label} deleted.`);
487
+ }
488
+ }}
489
+ />
490
+ ) : null}
491
+
492
+ {!sourceSafe ? (
493
+ <div className="squisq-ascii-timeline-warnings" role="status">
494
+ Visual editing paused: this timeline contains source the canvas cannot safely rewrite.
495
+ Repair it in Source view first.
496
+ {view.warnings.length > 0
497
+ ? ` (${view.warnings.length} parser note${view.warnings.length === 1 ? '' : 's'})`
498
+ : ''}
499
+ </div>
500
+ ) : null}
501
+ <div className="squisq-sr-only" aria-live="polite">
502
+ {announcement}
503
+ </div>
504
+ </section>
505
+ );
506
+ }
507
+
508
+ function EventInspector({
509
+ selected,
510
+ editable,
511
+ canDelete,
512
+ dispatch,
513
+ onDelete,
514
+ }: {
515
+ selected: SelectedEvent;
516
+ editable: boolean;
517
+ canDelete: boolean;
518
+ dispatch: (command: TimelineCommand) => TimelineCommandResult;
519
+ onDelete: () => void;
520
+ }) {
521
+ const { event } = selected;
522
+ const [label, setLabel] = useState(event.label);
523
+ const [description, setDescription] = useState(event.description ?? '');
524
+
525
+ useEffect(() => setLabel(event.label), [event.label]);
526
+ useEffect(() => setDescription(event.description ?? ''), [event.description]);
527
+
528
+ const update = (patch: TimelineCommand & { kind: 'updateEvent' }) => dispatch(patch);
529
+ const commitLabel = () => {
530
+ const next = label.trim();
531
+ if (!next) {
532
+ setLabel(event.label);
533
+ return;
534
+ }
535
+ if (next !== event.label) {
536
+ const result = update({ kind: 'updateEvent', eventId: event.id, patch: { label: next } });
537
+ if (!result.applied) setLabel(event.label);
538
+ }
539
+ };
540
+ const commitDescription = () => {
541
+ const next = description.trim();
542
+ if (next !== (event.description ?? '')) {
543
+ const result = update({
544
+ kind: 'updateEvent',
545
+ eventId: event.id,
546
+ patch: { description: next || undefined },
547
+ });
548
+ if (!result.applied) setDescription(event.description ?? '');
549
+ }
550
+ };
551
+
552
+ return (
553
+ <fieldset className="squisq-ascii-timeline-inspector" disabled={!editable}>
554
+ <legend>
555
+ Edit point <span>{selected.trackLabel}</span>
556
+ </legend>
557
+ <label>
558
+ <span>Label</span>
559
+ <input
560
+ value={label}
561
+ onChange={(event_) => setLabel(event_.target.value)}
562
+ onBlur={commitLabel}
563
+ onKeyDown={(event_) => {
564
+ if (event_.key === 'Enter') {
565
+ event_.preventDefault();
566
+ commitLabel();
567
+ event_.currentTarget.blur();
568
+ } else if (event_.key === 'Escape') {
569
+ setLabel(event.label);
570
+ event_.currentTarget.blur();
571
+ }
572
+ }}
573
+ />
574
+ </label>
575
+ <label className="squisq-ascii-timeline-inspector-description">
576
+ <span>Callout text</span>
577
+ <textarea
578
+ rows={2}
579
+ value={description}
580
+ onChange={(event_) => setDescription(event_.target.value)}
581
+ onBlur={commitDescription}
582
+ onKeyDown={(event_) => {
583
+ if ((event_.ctrlKey || event_.metaKey) && event_.key === 'Enter') {
584
+ event_.preventDefault();
585
+ commitDescription();
586
+ event_.currentTarget.blur();
587
+ } else if (event_.key === 'Escape') {
588
+ setDescription(event.description ?? '');
589
+ event_.currentTarget.blur();
590
+ }
591
+ }}
592
+ />
593
+ </label>
594
+ <label>
595
+ <span>Label side</span>
596
+ <select
597
+ value={event.side ?? 'above'}
598
+ onChange={(event_) =>
599
+ update({
600
+ kind: 'updateEvent',
601
+ eventId: event.id,
602
+ patch: { side: event_.target.value as AsciiTimelineSide },
603
+ })
604
+ }
605
+ >
606
+ <option value="above">Above</option>
607
+ <option value="below">Below</option>
608
+ </select>
609
+ </label>
610
+ <label>
611
+ <span>Marker</span>
612
+ <select
613
+ value={event.marker ?? 'filled'}
614
+ onChange={(event_) =>
615
+ update({
616
+ kind: 'updateEvent',
617
+ eventId: event.id,
618
+ patch: { marker: event_.target.value as AsciiTimelineMarker },
619
+ })
620
+ }
621
+ >
622
+ <option value="filled">Filled dot</option>
623
+ <option value="hollow">Hollow dot</option>
624
+ <option value="diamond">Diamond</option>
625
+ </select>
626
+ </label>
627
+ <label className="squisq-ascii-timeline-checkbox">
628
+ <input
629
+ type="checkbox"
630
+ checked={event.callout !== false}
631
+ onChange={(event_) =>
632
+ update({
633
+ kind: 'updateEvent',
634
+ eventId: event.id,
635
+ patch: { callout: event_.target.checked },
636
+ })
637
+ }
638
+ />
639
+ <span>Show callout</span>
640
+ </label>
641
+ <button
642
+ type="button"
643
+ className="squisq-ascii-timeline-delete"
644
+ disabled={!canDelete}
645
+ title={canDelete ? 'Delete point' : 'A timeline needs at least one point'}
646
+ onClick={onDelete}
647
+ >
648
+ <Icon icon="fa-solid fa-trash" /> Delete point
649
+ </button>
650
+ </fieldset>
651
+ );
652
+ }
653
+
654
+ function insertionGaps(positions: readonly number[]): number[] {
655
+ if (positions.length === 0) return [0.5];
656
+ const sorted = [...positions].sort((a, b) => a - b);
657
+ const bounds = [0, ...sorted, 1];
658
+ const gaps: number[] = [];
659
+ for (let index = 0; index < bounds.length - 1; index++) {
660
+ const start = bounds[index];
661
+ const end = bounds[index + 1];
662
+ if (end - start >= 0.09) gaps.push((start + end) / 2);
663
+ }
664
+ return gaps;
665
+ }
666
+
667
+ function largestGap(positions: readonly number[]): number {
668
+ if (positions.length === 0) return 0.5;
669
+ const sorted = [0, ...positions.map(clamp01).sort((a, b) => a - b), 1];
670
+ let start = sorted[0];
671
+ let end = sorted[1];
672
+ for (let index = 1; index < sorted.length - 1; index++) {
673
+ if (sorted[index + 1] - sorted[index] > end - start) {
674
+ start = sorted[index];
675
+ end = sorted[index + 1];
676
+ }
677
+ }
678
+ return (start + end) / 2;
679
+ }
680
+
681
+ function navigateTrackPoints(event: KeyboardEvent<HTMLButtonElement>): void {
682
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
683
+ const rail = event.currentTarget.closest('.squisq-ascii-timeline-rail');
684
+ if (!rail) return;
685
+ const buttons = Array.from(
686
+ rail.querySelectorAll<HTMLButtonElement>('.squisq-ascii-timeline-marker'),
687
+ );
688
+ const current = buttons.indexOf(event.currentTarget);
689
+ if (current < 0) return;
690
+ event.preventDefault();
691
+ const target =
692
+ event.key === 'Home'
693
+ ? buttons[0]
694
+ : event.key === 'End'
695
+ ? buttons[buttons.length - 1]
696
+ : event.key === 'ArrowLeft'
697
+ ? buttons[Math.max(0, current - 1)]
698
+ : buttons[Math.min(buttons.length - 1, current + 1)];
699
+ target?.focus();
700
+ }
701
+
702
+ export default TimelineEditorWidget;