@bendyline/squisq-editor-react 2.0.0 → 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 (55) hide show
  1. package/dist/index.d.ts +75 -33
  2. package/dist/index.js +1717 -935
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +145 -5
  5. package/package.json +4 -4
  6. package/src/DocumentSettingsDialog.tsx +32 -18
  7. package/src/EditorShell.tsx +1 -1
  8. package/src/PreviewControls.tsx +57 -24
  9. package/src/RecorderEntry.tsx +2 -0
  10. package/src/Toolbar.tsx +164 -19
  11. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  12. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  13. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  14. package/src/__tests__/previewControls.test.tsx +164 -0
  15. package/src/__tests__/recorderTheme.test.tsx +42 -0
  16. package/src/__tests__/selectionConversions.test.ts +80 -0
  17. package/src/__tests__/tiptapBridge.test.ts +48 -7
  18. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  19. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  20. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  21. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  22. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  23. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  24. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  25. package/src/codeContext/types.ts +1 -1
  26. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  27. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  28. package/src/diagram/DiagramCanvas.tsx +16 -2
  29. package/src/frontmatterSettings.ts +23 -0
  30. package/src/index.ts +7 -1
  31. package/src/recorder/RecorderButton.tsx +9 -1
  32. package/src/recorder/RecorderModal.tsx +84 -41
  33. package/src/recorder/RecorderPanel.tsx +9 -1
  34. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  35. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  36. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  37. package/src/scene/commands/SceneCommand.ts +13 -2
  38. package/src/scene/tools/SelectTool.ts +5 -5
  39. package/src/selectionConversions.ts +155 -0
  40. package/src/styles/ascii-timeline.css +101 -4
  41. package/src/styles/editor.css +30 -0
  42. package/src/styles/tree-view.css +51 -1
  43. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  44. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  45. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  46. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  47. package/src/timeline/timelineCommands.ts +54 -0
  48. package/src/timeline/timelineOps.ts +107 -3
  49. package/src/tiptapBridge.ts +23 -5
  50. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  51. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  52. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  53. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  54. package/src/treeview/treeOps.ts +59 -0
  55. package/src/treeview/treeViewCommands.ts +5 -0
@@ -13,7 +13,7 @@
13
13
 
14
14
  .squisq-ascii-timeline-editor {
15
15
  --squisq-ascii-timeline-accent: var(--squisq-theme-primary, #6366f1);
16
- --squisq-ascii-timeline-label-width: 120px;
16
+ --squisq-ascii-timeline-label-width: 160px;
17
17
  border: 1px solid var(--squisq-border, #e2e8f0);
18
18
  border-radius: 10px;
19
19
  background: var(--squisq-input-bg, #fff);
@@ -44,6 +44,33 @@
44
44
  color: var(--squisq-text-muted, #64748b);
45
45
  }
46
46
 
47
+ .squisq-ascii-timeline-header-actions {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: flex-end;
51
+ gap: 10px;
52
+ }
53
+
54
+ .squisq-ascii-timeline-header-actions button {
55
+ display: inline-flex;
56
+ align-items: center;
57
+ gap: 5px;
58
+ padding: 4px 8px;
59
+ border: 1px solid var(--squisq-border, #cbd5e1);
60
+ border-radius: 5px;
61
+ background: var(--squisq-input-bg, #fff);
62
+ color: var(--squisq-ascii-timeline-accent);
63
+ font: inherit;
64
+ font-size: 12px;
65
+ cursor: pointer;
66
+ }
67
+
68
+ .squisq-ascii-timeline-header-actions button:hover,
69
+ .squisq-ascii-timeline-header-actions button:focus-visible {
70
+ border-color: var(--squisq-ascii-timeline-accent);
71
+ outline: 2px solid color-mix(in srgb, var(--squisq-ascii-timeline-accent) 22%, transparent);
72
+ }
73
+
47
74
  .squisq-ascii-timeline-canvas {
48
75
  position: relative;
49
76
  min-width: 600px;
@@ -72,9 +99,70 @@
72
99
  white-space: nowrap;
73
100
  }
74
101
 
75
- .squisq-ascii-timeline-track-label > span {
102
+ .squisq-ascii-timeline-track-name {
103
+ min-width: 0;
104
+ flex: 1 1 auto;
105
+ padding: 3px 4px;
76
106
  overflow: hidden;
107
+ border: 1px solid transparent;
108
+ border-radius: 4px;
109
+ background: transparent;
110
+ color: inherit;
111
+ font: inherit;
112
+ text-align: right;
77
113
  text-overflow: ellipsis;
114
+ white-space: nowrap;
115
+ }
116
+
117
+ button.squisq-ascii-timeline-track-name {
118
+ cursor: text;
119
+ }
120
+
121
+ button.squisq-ascii-timeline-track-name:hover,
122
+ button.squisq-ascii-timeline-track-name:focus-visible {
123
+ border-color: var(--squisq-border, #cbd5e1);
124
+ background: var(--squisq-input-bg, #fff);
125
+ outline: none;
126
+ }
127
+
128
+ .squisq-ascii-timeline-track-name-input {
129
+ width: 100%;
130
+ min-width: 0;
131
+ flex: 1 1 auto;
132
+ padding: 3px 4px;
133
+ border: 1px solid var(--squisq-ascii-timeline-accent);
134
+ border-radius: 4px;
135
+ background: var(--squisq-input-bg, #fff);
136
+ color: var(--squisq-text, #1e293b);
137
+ font: inherit;
138
+ text-align: right;
139
+ outline: 2px solid color-mix(in srgb, var(--squisq-ascii-timeline-accent) 22%, transparent);
140
+ }
141
+
142
+ .squisq-ascii-timeline-track-delete {
143
+ display: grid;
144
+ width: 22px;
145
+ height: 22px;
146
+ flex: 0 0 22px;
147
+ place-items: center;
148
+ padding: 0;
149
+ border: 0;
150
+ border-radius: 4px;
151
+ background: transparent;
152
+ color: var(--squisq-text-muted, #64748b);
153
+ cursor: pointer;
154
+ }
155
+
156
+ .squisq-ascii-timeline-track-delete:hover,
157
+ .squisq-ascii-timeline-track-delete:focus-visible {
158
+ background: color-mix(in srgb, #ef4444 12%, transparent);
159
+ color: #dc2626;
160
+ outline: 2px solid color-mix(in srgb, #ef4444 25%, transparent);
161
+ }
162
+
163
+ .squisq-ascii-timeline-track-delete:disabled {
164
+ opacity: 0.35;
165
+ cursor: not-allowed;
78
166
  }
79
167
 
80
168
  .squisq-ascii-timeline-track-add {
@@ -95,7 +183,8 @@
95
183
  }
96
184
 
97
185
  .squisq-ascii-timeline-track-add:hover,
98
- .squisq-ascii-timeline-track-add:focus-visible {
186
+ .squisq-ascii-timeline-track-add:focus-visible,
187
+ .squisq-ascii-timeline-track-add[aria-pressed='true'] {
99
188
  border-color: var(--squisq-ascii-timeline-accent);
100
189
  outline: 2px solid color-mix(in srgb, var(--squisq-ascii-timeline-accent) 22%, transparent);
101
190
  }
@@ -105,6 +194,10 @@
105
194
  height: 96px;
106
195
  min-width: 420px;
107
196
  outline: none;
197
+ cursor: default;
198
+ }
199
+
200
+ .squisq-ascii-timeline-rail--adding {
108
201
  cursor: crosshair;
109
202
  }
110
203
 
@@ -460,7 +553,7 @@
460
553
 
461
554
  @media (max-width: 760px) {
462
555
  .squisq-ascii-timeline-editor {
463
- --squisq-ascii-timeline-label-width: 88px;
556
+ --squisq-ascii-timeline-label-width: 132px;
464
557
  }
465
558
 
466
559
  .squisq-ascii-timeline-inspector {
@@ -475,6 +568,10 @@
475
568
  .squisq-ascii-timeline-delete {
476
569
  grid-column: 2 / 3;
477
570
  }
571
+
572
+ .squisq-ascii-timeline-header-actions small {
573
+ display: none;
574
+ }
478
575
  }
479
576
 
480
577
  @media (prefers-reduced-motion: reduce) {
@@ -645,6 +645,19 @@
645
645
  z-index: 100;
646
646
  min-width: 200px;
647
647
  padding: 4px 0;
648
+ /* This menu is portaled to <body>, outside the editor shell that normally
649
+ supplies the UX font. Keep its unbuttoned content (notably the header)
650
+ on the same sans-serif UI stack instead of inheriting the host page font. */
651
+ font-family: var(
652
+ --squisq-ux-font,
653
+ -apple-system,
654
+ BlinkMacSystemFont,
655
+ 'Segoe UI',
656
+ 'Noto Sans',
657
+ Helvetica,
658
+ Arial,
659
+ sans-serif
660
+ );
648
661
  background: #fff;
649
662
  border: 1px solid #e5e7eb;
650
663
  border-radius: 6px;
@@ -661,6 +674,12 @@
661
674
  user-select: none;
662
675
  }
663
676
 
677
+ .squisq-insert-menu-header--separated {
678
+ margin-top: 4px;
679
+ padding-top: 8px;
680
+ border-top: 1px solid #e5e7eb;
681
+ }
682
+
664
683
  /* Portaled outside squisq-editor-shell, so dark mode is applied via data-theme. */
665
684
  .squisq-insert-menu[data-theme='dark'] {
666
685
  background: #1f2937;
@@ -680,6 +699,10 @@
680
699
  color: #6b7280;
681
700
  }
682
701
 
702
+ .squisq-insert-menu[data-theme='dark'] .squisq-insert-menu-header--separated {
703
+ border-top-color: #374151;
704
+ }
705
+
683
706
  /* ─── Template Picker (toolbar) ──────────────────────── */
684
707
 
685
708
  .squisq-template-picker {
@@ -2066,6 +2089,13 @@
2066
2089
  color: var(--squisq-theme-text-muted, #6b7280);
2067
2090
  }
2068
2091
 
2092
+ /* Consecutive Markdown quote lines share one blockquote. Keep their Tiptap
2093
+ paragraphs visually line-adjacent instead of applying the normal prose
2094
+ paragraph gap between every authored `>` line. */
2095
+ .squisq-wysiwyg-editor blockquote > p {
2096
+ margin: 0;
2097
+ }
2098
+
2069
2099
  /* Inline `code` and `<pre>` blocks: the foreground / background draw from
2070
2100
  the active theme when the WYSIWYG container inherits theme colors
2071
2101
  (View menu → "Editor styling from theme: Fonts & colors"). The
@@ -48,7 +48,10 @@
48
48
  border-color: var(--squisq-text-muted, #94a3b8);
49
49
  }
50
50
 
51
- .squisq-tree-rows {
51
+ /* Include the widget wrapper so this reset wins over the editor's generic
52
+ * `.squisq-wysiwyg-editor ul` padding. Otherwise every nested rail picks up
53
+ * another 24px of list indentation. */
54
+ .squisq-tree-outline .squisq-tree-rows {
52
55
  list-style: none;
53
56
  margin: 0;
54
57
  padding: 0;
@@ -58,16 +61,51 @@
58
61
  margin-left: 8px;
59
62
  }
60
63
 
64
+ .squisq-tree-item {
65
+ position: relative;
66
+ }
67
+
61
68
  .squisq-tree-row {
69
+ position: relative;
62
70
  display: flex;
63
71
  align-items: center;
64
72
  gap: 4px;
65
73
  padding: 1px 0;
74
+ border-radius: 4px;
66
75
  }
67
76
  .squisq-tree-row:hover .squisq-tree-controls {
68
77
  opacity: 1;
69
78
  }
70
79
 
80
+ .squisq-tree-item--dragging {
81
+ opacity: 0.42;
82
+ }
83
+
84
+ .squisq-tree-item--drop-before > .squisq-tree-row::before,
85
+ .squisq-tree-item--drop-after::after {
86
+ content: '';
87
+ position: absolute;
88
+ z-index: 2;
89
+ left: 0;
90
+ right: 0;
91
+ height: 2px;
92
+ border-radius: 999px;
93
+ background: #6366f1;
94
+ pointer-events: none;
95
+ }
96
+ .squisq-tree-item--drop-before > .squisq-tree-row::before {
97
+ top: -1px;
98
+ }
99
+ /* An after-drop becomes the target's sibling, so show the marker below its
100
+ * complete subtree rather than directly below the target row. */
101
+ .squisq-tree-item--drop-after::after {
102
+ bottom: -1px;
103
+ }
104
+ .squisq-tree-item--drop-child > .squisq-tree-row {
105
+ background: rgba(99, 102, 241, 0.12);
106
+ box-shadow: inset 0 0 0 1px #6366f1;
107
+ }
108
+
71
109
  .squisq-tree-chevron {
72
110
  flex: 0 0 auto;
73
111
  width: 16px;
@@ -115,6 +153,18 @@
115
153
  opacity: 0;
116
154
  transition: opacity 0.12s;
117
155
  }
156
+ .squisq-tree-drag-handle {
157
+ flex: 0 0 auto;
158
+ display: inline-flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ width: 16px;
162
+ color: var(--squisq-text-muted, #64748b);
163
+ cursor: grab;
164
+ }
165
+ .squisq-tree-drag-handle:active {
166
+ cursor: grabbing;
167
+ }
118
168
  .squisq-tree-controls button {
119
169
  border: none;
120
170
  background: transparent;
@@ -56,6 +56,8 @@ const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
56
56
  export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetProps) {
57
57
  const view = useTimelineData(editor, blockId);
58
58
  const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
59
+ const [addingTrackId, setAddingTrackId] = useState<string | null>(null);
60
+ const [editingTrackId, setEditingTrackId] = useState<string | null>(null);
59
61
  const [hover, setHover] = useState<{ trackId: string; position: number } | null>(null);
60
62
  const [dragged, setDragged] = useState<DraggedEvent | null>(null);
61
63
  const suppressClickEventId = useRef<string | null>(null);
@@ -86,6 +88,8 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
86
88
  // editor's cursor/selection hot path.
87
89
  [sourceText, sourceTimeline],
88
90
  );
91
+ const editorEditable = editor.isEditable;
92
+ const editable = editorEditable && sourceSafe;
89
93
 
90
94
  const positioned = useMemo(() => {
91
95
  if (!view) return [];
@@ -142,25 +146,65 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
142
146
  setSelectedEventId(view.timeline.tracks[0]?.events[0]?.id ?? null);
143
147
  }, [selectedEventId, view]);
144
148
 
149
+ useEffect(() => {
150
+ if (editable) return;
151
+ setAddingTrackId(null);
152
+ setEditingTrackId(null);
153
+ setHover(null);
154
+ }, [editable]);
155
+
156
+ useEffect(() => {
157
+ if (!editingTrackId || view?.timeline.tracks.some((track) => track.id === editingTrackId)) {
158
+ return;
159
+ }
160
+ setEditingTrackId(null);
161
+ }, [editingTrackId, view]);
162
+
145
163
  if (!view) return null;
146
164
 
147
- const editorEditable = editor.isEditable;
148
- const editable = editorEditable && sourceSafe;
149
165
  const totalEvents = positioned.length;
166
+ const addTrack = () => {
167
+ if (!editable) return;
168
+ const result = dispatch({ kind: 'addTrack' });
169
+ if (result.applied && result.eventId && result.trackId) {
170
+ setAddingTrackId(null);
171
+ setEditingTrackId(result.trackId);
172
+ setHover(null);
173
+ setSelectedEventId(result.eventId);
174
+ setAnnouncement('New timeline line added. Edit its name on the line.');
175
+ }
176
+ };
177
+
150
178
  const addEvent = (trackId: string, position: number) => {
151
179
  if (!editable) return;
152
180
  const result = dispatch({ kind: 'addEvent', trackId, position: clamp01(position) });
153
181
  if (result.applied && result.eventId) {
182
+ setAddingTrackId(null);
183
+ setEditingTrackId(null);
184
+ setHover(null);
154
185
  setSelectedEventId(result.eventId);
155
186
  setAnnouncement('New timeline point added. Edit its text below.');
156
187
  }
157
188
  };
158
189
 
159
190
  const selectEvent = (event: AsciiTimelineEvent, trackLabel: string) => {
191
+ setAddingTrackId(null);
192
+ setHover(null);
160
193
  setSelectedEventId(event.id);
161
194
  setAnnouncement(`${event.label || 'Timeline point'} selected on ${trackLabel}.`);
162
195
  };
163
196
 
197
+ const deleteTrack = (trackId: string, trackLabel: string) => {
198
+ const remaining = positioned.filter((point) => point.trackId !== trackId);
199
+ const result = dispatch({ kind: 'removeTrack', trackId });
200
+ if (!result.applied) return;
201
+ setAddingTrackId(null);
202
+ setEditingTrackId((current) => (current === trackId ? null : current));
203
+ setHover(null);
204
+ if (selected?.trackId === trackId) setSelectedEventId(remaining[0]?.event.id ?? null);
205
+ setAnnouncement(`${trackLabel} line deleted.`);
206
+ };
207
+
164
208
  const dragPosition = (event: ReactPointerEvent<HTMLButtonElement>): number | null => {
165
209
  const rail = event.currentTarget.closest<HTMLElement>('.squisq-ascii-timeline-rail');
166
210
  if (!rail) return null;
@@ -170,18 +214,36 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
170
214
  };
171
215
 
172
216
  return (
173
- <section className="squisq-ascii-timeline-editor" aria-label="Timeline editor">
217
+ <section
218
+ className="squisq-ascii-timeline-editor"
219
+ aria-label="Timeline editor"
220
+ onKeyDown={(event) => {
221
+ if (event.key !== 'Escape' || !addingTrackId) return;
222
+ setAddingTrackId(null);
223
+ setHover(null);
224
+ setAnnouncement('Add point cancelled.');
225
+ }}
226
+ >
174
227
  <header className="squisq-ascii-timeline-header">
175
228
  <span>
176
229
  <Icon icon="fa-solid fa-timeline" /> Timeline
177
230
  </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
- )}
231
+ <div className="squisq-ascii-timeline-header-actions">
232
+ {editable && addingTrackId ? (
233
+ <small>Click the line to add a point · Esc to cancel</small>
234
+ ) : editable ? (
235
+ <small>Use + to add a point · Drag dots to move</small>
236
+ ) : editorEditable ? (
237
+ <small>Source repair needed</small>
238
+ ) : (
239
+ <small>Read only</small>
240
+ )}
241
+ {editable ? (
242
+ <button type="button" onClick={addTrack}>
243
+ <Icon icon="fa-solid fa-plus" /> Add line
244
+ </button>
245
+ ) : null}
246
+ </div>
185
247
  </header>
186
248
 
187
249
  <div
@@ -227,29 +289,68 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
227
289
  .sort((a, b) => a.position - b.position);
228
290
  const eventPositions = events.map((entry) => entry.position);
229
291
  const gaps = insertionGaps(eventPositions);
292
+ const addingPoint = editable && addingTrackId === track.id;
230
293
  return (
231
294
  <div className="squisq-ascii-timeline-track" key={track.id}>
232
- <div className="squisq-ascii-timeline-track-label" title={trackLabel}>
233
- <span>{trackLabel}</span>
295
+ <div className="squisq-ascii-timeline-track-label">
296
+ <InlineTrackLabel
297
+ trackId={track.id}
298
+ label={trackLabel}
299
+ editing={editable && editingTrackId === track.id}
300
+ editable={editable}
301
+ dispatch={dispatch}
302
+ onStart={() => {
303
+ setAddingTrackId(null);
304
+ setHover(null);
305
+ setEditingTrackId(track.id);
306
+ }}
307
+ onFinish={() => setEditingTrackId(null)}
308
+ />
234
309
  {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>
310
+ <>
311
+ <button
312
+ type="button"
313
+ className="squisq-ascii-timeline-track-delete"
314
+ aria-label={`Delete line: ${trackLabel}`}
315
+ disabled={view.timeline.tracks.length <= 1}
316
+ title={
317
+ view.timeline.tracks.length > 1
318
+ ? `Delete ${trackLabel} line and all of its points`
319
+ : 'A timeline needs one line'
320
+ }
321
+ onClick={() => deleteTrack(track.id, trackLabel)}
322
+ >
323
+ <Icon icon="fa-solid fa-trash" />
324
+ </button>
325
+ <button
326
+ type="button"
327
+ className="squisq-ascii-timeline-track-add"
328
+ aria-label={`Add point to ${trackLabel} timeline`}
329
+ aria-pressed={addingPoint}
330
+ title={addingPoint ? 'Cancel adding a point' : 'Choose where to add a point'}
331
+ onClick={() => {
332
+ setEditingTrackId(null);
333
+ setAddingTrackId((current) => (current === track.id ? null : track.id));
334
+ setHover(null);
335
+ setAnnouncement(
336
+ addingPoint
337
+ ? 'Add point cancelled.'
338
+ : `Choose where to add a point on ${trackLabel}.`,
339
+ );
340
+ }}
341
+ >
342
+ +
343
+ </button>
344
+ </>
244
345
  ) : null}
245
346
  </div>
246
347
  <div
247
- className={`squisq-ascii-timeline-rail${editable ? '' : ' squisq-ascii-timeline-rail--readonly'}`}
348
+ className={`squisq-ascii-timeline-rail${addingPoint ? ' squisq-ascii-timeline-rail--adding' : ''}${editable ? '' : ' squisq-ascii-timeline-rail--readonly'}`}
248
349
  role="group"
249
350
  aria-label={`${trackLabel} timeline rail`}
250
- title={editable ? 'Click anywhere on the line to add a point' : undefined}
351
+ title={addingPoint ? 'Click anywhere on the line to add a point' : undefined}
251
352
  onPointerMove={(event) => {
252
- if (!editable || dragged) return;
353
+ if (!addingPoint || dragged) return;
253
354
  const rect = event.currentTarget.getBoundingClientRect();
254
355
  if (rect.width <= 0) return;
255
356
  setHover({
@@ -261,14 +362,14 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
261
362
  setHover((current) => (current?.trackId === track.id ? null : current))
262
363
  }
263
364
  onClick={(event) => {
264
- if (!editable || event.target !== event.currentTarget) return;
365
+ if (!addingPoint || event.target !== event.currentTarget) return;
265
366
  const rect = event.currentTarget.getBoundingClientRect();
266
367
  if (rect.width <= 0) return;
267
368
  addEvent(track.id, (event.clientX - rect.left) / rect.width);
268
369
  }}
269
370
  >
270
371
  <span className="squisq-ascii-timeline-rail-line" aria-hidden="true" />
271
- {hover?.trackId === track.id && !dragged ? (
372
+ {addingPoint && hover?.trackId === track.id && !dragged ? (
272
373
  <span
273
374
  className="squisq-ascii-timeline-add-ghost"
274
375
  style={{ left: `${hover.position * 100}%` }}
@@ -278,7 +379,7 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
278
379
  </span>
279
380
  ) : null}
280
381
 
281
- {editable && !dragged
382
+ {addingPoint && !dragged
282
383
  ? gaps.map((position) => (
283
384
  <button
284
385
  type="button"
@@ -344,6 +445,7 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
344
445
  const point = dragPosition(pointerEvent);
345
446
  if (point === null) return;
346
447
  pointerEvent.stopPropagation();
448
+ setAddingTrackId(null);
347
449
  setHover(null);
348
450
  setSelectedEventId(event.id);
349
451
  suppressClickEventId.current = null;
@@ -505,6 +607,77 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
505
607
  );
506
608
  }
507
609
 
610
+ function InlineTrackLabel({
611
+ trackId,
612
+ label,
613
+ editing,
614
+ editable,
615
+ dispatch,
616
+ onStart,
617
+ onFinish,
618
+ }: {
619
+ trackId: string;
620
+ label: string;
621
+ editing: boolean;
622
+ editable: boolean;
623
+ dispatch: (command: TimelineCommand) => TimelineCommandResult;
624
+ onStart: () => void;
625
+ onFinish: () => void;
626
+ }) {
627
+ const [draft, setDraft] = useState(label);
628
+
629
+ useEffect(() => {
630
+ if (editing) setDraft(label);
631
+ }, [editing, label]);
632
+
633
+ const commitLabel = () => {
634
+ const next = draft.trim();
635
+ if (!next) {
636
+ setDraft(label);
637
+ } else if (next !== label) {
638
+ const result = dispatch({ kind: 'updateTrack', trackId, label: next });
639
+ if (!result.applied) setDraft(label);
640
+ }
641
+ onFinish();
642
+ };
643
+
644
+ if (!editable) return <span className="squisq-ascii-timeline-track-name">{label}</span>;
645
+
646
+ return editing ? (
647
+ <input
648
+ className="squisq-ascii-timeline-track-name-input"
649
+ aria-label={`Rename line: ${label}`}
650
+ value={draft}
651
+ autoFocus
652
+ onFocus={(event) => event.currentTarget.select()}
653
+ onChange={(event) => setDraft(event.target.value)}
654
+ onBlur={commitLabel}
655
+ onKeyDown={(event) => {
656
+ if (event.key === 'Enter') {
657
+ event.preventDefault();
658
+ event.stopPropagation();
659
+ commitLabel();
660
+ } else if (event.key === 'Escape') {
661
+ event.preventDefault();
662
+ event.stopPropagation();
663
+ setDraft(label);
664
+ onFinish();
665
+ }
666
+ }}
667
+ />
668
+ ) : (
669
+ <button
670
+ type="button"
671
+ className="squisq-ascii-timeline-track-name"
672
+ aria-label={`Rename line: ${label}`}
673
+ title={`Rename ${label}`}
674
+ onClick={onStart}
675
+ >
676
+ {label}
677
+ </button>
678
+ );
679
+ }
680
+
508
681
  function EventInspector({
509
682
  selected,
510
683
  editable,
@@ -664,20 +837,6 @@ function insertionGaps(positions: readonly number[]): number[] {
664
837
  return gaps;
665
838
  }
666
839
 
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
840
  function navigateTrackPoints(event: KeyboardEvent<HTMLButtonElement>): void {
682
841
  if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
683
842
  const rail = event.currentTarget.closest('.squisq-ascii-timeline-rail');