@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.
- package/dist/index.d.ts +75 -33
- package/dist/index.js +1717 -935
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +145 -5
- package/package.json +4 -4
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorShell.tsx +1 -1
- package/src/PreviewControls.tsx +57 -24
- package/src/RecorderEntry.tsx +2 -0
- package/src/Toolbar.tsx +164 -19
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/previewControls.test.tsx +164 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +48 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
- package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
- package/src/asciiDiagram/asciiDiagramData.ts +33 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
- package/src/codeContext/types.ts +1 -1
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +16 -2
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +7 -1
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +13 -2
- package/src/scene/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/editor.css +30 -0
- package/src/styles/tree-view.css +51 -1
- package/src/timeline/TimelineEditorWidget.tsx +200 -41
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
- package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
- package/src/timeline/__tests__/timelineOps.test.ts +55 -0
- package/src/timeline/timelineCommands.ts +54 -0
- package/src/timeline/timelineOps.ts +107 -3
- package/src/tiptapBridge.ts +23 -5
- package/src/treeview/TreeOutlineWidget.tsx +153 -3
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/treeOps.test.ts +52 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
- package/src/treeview/treeOps.ts +59 -0
- 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:
|
|
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-
|
|
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:
|
|
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) {
|
package/src/styles/editor.css
CHANGED
|
@@ -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
|
package/src/styles/tree-view.css
CHANGED
|
@@ -48,7 +48,10 @@
|
|
|
48
48
|
border-color: var(--squisq-text-muted, #94a3b8);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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"
|
|
233
|
-
<
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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={
|
|
351
|
+
title={addingPoint ? 'Click anywhere on the line to add a point' : undefined}
|
|
251
352
|
onPointerMove={(event) => {
|
|
252
|
-
if (!
|
|
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 (!
|
|
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
|
-
{
|
|
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');
|