@bendyline/squisq-editor-react 1.5.3 → 1.6.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.
- package/dist/index.d.ts +757 -20
- package/dist/index.js +16875 -6883
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +143 -0
- package/src/EditorShell.tsx +200 -120
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +38 -3
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +180 -8
- package/src/RawEditor.tsx +169 -13
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +528 -174
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +2 -14
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +24 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +77 -0
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +8 -1
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1615 -203
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +4 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +23 -1
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
FolderView — standalone folder browser surface
|
|
3
|
+
==========================================================================
|
|
4
|
+
Companion to the editor shell: a list of files/subfolders plus a
|
|
5
|
+
prominent "New" action. Host-agnostic; mirrors the shell's chrome,
|
|
6
|
+
palette, and `data-theme` dark mode. */
|
|
7
|
+
|
|
8
|
+
.squisq-folder-view {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
min-height: 0;
|
|
12
|
+
font-family: var(
|
|
13
|
+
--squisq-ux-font,
|
|
14
|
+
-apple-system,
|
|
15
|
+
BlinkMacSystemFont,
|
|
16
|
+
'Segoe UI',
|
|
17
|
+
'Noto Sans',
|
|
18
|
+
Helvetica,
|
|
19
|
+
Arial,
|
|
20
|
+
sans-serif
|
|
21
|
+
);
|
|
22
|
+
color: #1f2937;
|
|
23
|
+
background: #fff;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* ─── Header ─────────────────────────────────────────── */
|
|
27
|
+
|
|
28
|
+
.squisq-folder-view-header {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 8px;
|
|
32
|
+
padding: 10px 14px;
|
|
33
|
+
border-bottom: 1px solid #e5e7eb;
|
|
34
|
+
background: #f9fafb;
|
|
35
|
+
flex-shrink: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.squisq-folder-view-header-icon {
|
|
39
|
+
color: #6b7280;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.squisq-folder-view-title {
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
font-size: 0.95rem;
|
|
45
|
+
white-space: nowrap;
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
text-overflow: ellipsis;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.squisq-folder-view-count {
|
|
51
|
+
margin-left: auto;
|
|
52
|
+
color: #6b7280;
|
|
53
|
+
font-size: 0.8rem;
|
|
54
|
+
flex-shrink: 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ─── List ───────────────────────────────────────────── */
|
|
58
|
+
|
|
59
|
+
.squisq-folder-view-body {
|
|
60
|
+
flex: 1;
|
|
61
|
+
min-height: 0;
|
|
62
|
+
overflow: auto;
|
|
63
|
+
padding: 8px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.squisq-folder-view-empty {
|
|
67
|
+
margin: 0;
|
|
68
|
+
padding: 24px 16px;
|
|
69
|
+
color: #6b7280;
|
|
70
|
+
font-size: 0.9rem;
|
|
71
|
+
text-align: center;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.squisq-folder-view-list {
|
|
75
|
+
list-style: none;
|
|
76
|
+
margin: 0;
|
|
77
|
+
padding: 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.squisq-folder-view-item {
|
|
81
|
+
width: 100%;
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
gap: 10px;
|
|
85
|
+
padding: 8px 10px;
|
|
86
|
+
background: transparent;
|
|
87
|
+
border: none;
|
|
88
|
+
border-radius: 6px;
|
|
89
|
+
color: inherit;
|
|
90
|
+
font: inherit;
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
text-align: left;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.squisq-folder-view-item:hover {
|
|
96
|
+
background: rgba(0, 0, 0, 0.05);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.squisq-folder-view-item-icon {
|
|
100
|
+
flex-shrink: 0;
|
|
101
|
+
display: inline-flex;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
color: #6b7280;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.squisq-folder-view-item-name {
|
|
107
|
+
flex: 1;
|
|
108
|
+
min-width: 0;
|
|
109
|
+
white-space: nowrap;
|
|
110
|
+
overflow: hidden;
|
|
111
|
+
text-overflow: ellipsis;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.squisq-folder-view-item-chevron {
|
|
115
|
+
flex-shrink: 0;
|
|
116
|
+
color: #9ca3af;
|
|
117
|
+
font-size: 0.7rem;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ─── New actions ────────────────────────────────────── */
|
|
121
|
+
|
|
122
|
+
.squisq-folder-view-actions {
|
|
123
|
+
flex-shrink: 0;
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 8px;
|
|
128
|
+
padding: 16px;
|
|
129
|
+
border-top: 1px solid #e5e7eb;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.squisq-folder-view-new {
|
|
133
|
+
width: 100%;
|
|
134
|
+
max-width: 360px;
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
justify-content: center;
|
|
138
|
+
gap: 8px;
|
|
139
|
+
padding: 14px 20px;
|
|
140
|
+
font-size: 1.05rem;
|
|
141
|
+
font-weight: 600;
|
|
142
|
+
color: #fff;
|
|
143
|
+
background: #3b82f6;
|
|
144
|
+
border: none;
|
|
145
|
+
border-radius: 10px;
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
transition: background 0.12s ease;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.squisq-folder-view-new:hover {
|
|
151
|
+
background: #2563eb;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.squisq-folder-view-new-folder {
|
|
155
|
+
display: inline-flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 6px;
|
|
158
|
+
padding: 6px 12px;
|
|
159
|
+
font-size: 0.85rem;
|
|
160
|
+
color: #6b7280;
|
|
161
|
+
background: transparent;
|
|
162
|
+
border: none;
|
|
163
|
+
border-radius: 6px;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.squisq-folder-view-new-folder:hover {
|
|
168
|
+
color: #2563eb;
|
|
169
|
+
background: rgba(37, 99, 235, 0.08);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ─── Dark mode (data-theme="dark") ──────────────────── */
|
|
173
|
+
|
|
174
|
+
.squisq-folder-view[data-theme='dark'] {
|
|
175
|
+
color: #e5e7eb;
|
|
176
|
+
background: #111827;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-header {
|
|
180
|
+
background: #1f2937;
|
|
181
|
+
border-bottom-color: #374151;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-header-icon,
|
|
185
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-count,
|
|
186
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-item-icon,
|
|
187
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-empty {
|
|
188
|
+
color: #9ca3af;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-item:hover {
|
|
192
|
+
background: rgba(255, 255, 255, 0.06);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-actions {
|
|
196
|
+
border-top-color: #374151;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-new:hover {
|
|
200
|
+
background: #60a5fa;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-new-folder {
|
|
204
|
+
color: #9ca3af;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.squisq-folder-view[data-theme='dark'] .squisq-folder-view-new-folder:hover {
|
|
208
|
+
color: #93c5fd;
|
|
209
|
+
background: rgba(96, 165, 250, 0.12);
|
|
210
|
+
}
|
|
@@ -109,8 +109,8 @@
|
|
|
109
109
|
.squisq-image-edit-modal__surface {
|
|
110
110
|
display: flex;
|
|
111
111
|
flex-direction: column;
|
|
112
|
-
width:
|
|
113
|
-
height:
|
|
112
|
+
width: 100%;
|
|
113
|
+
height: 100%;
|
|
114
114
|
background: #fff;
|
|
115
115
|
border-radius: 6px;
|
|
116
116
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
package/src/styles/index.css
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
@import '@fortawesome/fontawesome-free/css/all.min.css';
|
|
7
7
|
@import './editor.css';
|
|
8
|
+
@import './folder-view.css';
|
|
8
9
|
@import '../jsonEditor/json-editor.css';
|
|
9
10
|
@import '../imageEditor/image-editor.css';
|
|
10
11
|
@import './image-edit-affordance.css';
|
|
12
|
+
@import './diagram.css';
|
|
13
|
+
@import '../scene/scene.css';
|
|
14
|
+
@import '../customTemplates/designer.css';
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timelineSource
|
|
3
|
+
*
|
|
4
|
+
* Line-level markdown rewrites for the timeline editor: set a block's
|
|
5
|
+
* `duration` on its heading's Pandoc attribute block, and patch a media
|
|
6
|
+
* clip's `startAt` / `clipStart` / `clipEnd` / `spillover` on its `{[audio …]}`
|
|
7
|
+
* / `{[video …]}` annotation line. Both preserve everything else on the line
|
|
8
|
+
* (template annotations, ids, classes, other params) by reusing the shared
|
|
9
|
+
* tokenizers rather than regex-replacing values.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
matchTrailingPandocAttr,
|
|
14
|
+
matchTrailingTemplateAnnotation,
|
|
15
|
+
parsePandocAttrTokens,
|
|
16
|
+
serializePandocAttributes,
|
|
17
|
+
tokenizeAttrTokens,
|
|
18
|
+
splitKeyValueToken,
|
|
19
|
+
quoteAttrValue,
|
|
20
|
+
} from '@bendyline/squisq/markdown';
|
|
21
|
+
|
|
22
|
+
/** Format a seconds value compactly: integers bare, else up to 2 decimals. */
|
|
23
|
+
export function formatSeconds(seconds: number): string {
|
|
24
|
+
const clamped = Math.max(0, seconds);
|
|
25
|
+
if (Number.isInteger(clamped)) return String(clamped);
|
|
26
|
+
return String(Math.round(clamped * 100) / 100);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Set/insert a `duration` on the heading at 1-based `line`, written in the
|
|
31
|
+
* squisq-native squiggly form — `{[duration=<seconds>]}` on its own, or
|
|
32
|
+
* folded into an existing `{[template …]}` annotation. Preserves any `{#id}`,
|
|
33
|
+
* classes, and other Pandoc params, and migrates a legacy Pandoc
|
|
34
|
+
* `{duration=…}` to the squiggly form (dropping the stale Pandoc key so the
|
|
35
|
+
* two can't disagree). Returns the new full source, or null when the line
|
|
36
|
+
* isn't an ATX heading.
|
|
37
|
+
*/
|
|
38
|
+
export function setBlockDurationInSource(
|
|
39
|
+
source: string,
|
|
40
|
+
line: number,
|
|
41
|
+
seconds: number,
|
|
42
|
+
): string | null {
|
|
43
|
+
const lines = source.split('\n');
|
|
44
|
+
const idx = line - 1;
|
|
45
|
+
if (idx < 0 || idx >= lines.length) return null;
|
|
46
|
+
const original = lines[idx];
|
|
47
|
+
if (!/^#{1,6}\s/.test(original)) return null;
|
|
48
|
+
|
|
49
|
+
const value = formatSeconds(seconds);
|
|
50
|
+
|
|
51
|
+
// Peel the trailing `{[…]}` annotation (if any) off the line; we'll fold
|
|
52
|
+
// `duration` into it. Canonical heading order is `{#pandoc} {[squiggly]}`,
|
|
53
|
+
// so the squiggly annotation is the last `{…}` on the line.
|
|
54
|
+
let rest = original;
|
|
55
|
+
let annotationInner: string | null = null;
|
|
56
|
+
const tpl = matchTrailingTemplateAnnotation(rest);
|
|
57
|
+
if (tpl) {
|
|
58
|
+
annotationInner = tpl.inner.trim();
|
|
59
|
+
rest = rest.slice(0, tpl.index).replace(/\s+$/, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Then peel a trailing Pandoc `{…}` block; drop its `duration` so a legacy
|
|
63
|
+
// value can't shadow the squiggly one we're writing. Keep the rest (id,
|
|
64
|
+
// classes, other params).
|
|
65
|
+
let pandocSerialized: string | null = null;
|
|
66
|
+
const pa = matchTrailingPandocAttr(rest);
|
|
67
|
+
if (pa) {
|
|
68
|
+
const attrs = parsePandocAttrTokens(pa.inner.trim());
|
|
69
|
+
if (attrs.params) {
|
|
70
|
+
delete attrs.params.duration;
|
|
71
|
+
if (Object.keys(attrs.params).length === 0) delete attrs.params;
|
|
72
|
+
}
|
|
73
|
+
pandocSerialized = serializePandocAttributes(attrs);
|
|
74
|
+
rest = rest.slice(0, pa.index).replace(/\s+$/, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const annotation = setTemplateParam(annotationInner, 'duration', value);
|
|
78
|
+
const parts = [rest];
|
|
79
|
+
if (pandocSerialized) parts.push(pandocSerialized);
|
|
80
|
+
parts.push(annotation);
|
|
81
|
+
lines[idx] = parts.join(' ');
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a `{[…]}` annotation from an existing inner string (or null for a
|
|
87
|
+
* fresh one), setting `key=value` and preserving the template name plus any
|
|
88
|
+
* other params in their original order.
|
|
89
|
+
*/
|
|
90
|
+
function setTemplateParam(inner: string | null, key: string, value: string): string {
|
|
91
|
+
const tokens = inner ? tokenizeAttrTokens(inner) : [];
|
|
92
|
+
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
93
|
+
const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
|
|
94
|
+
|
|
95
|
+
const params: Record<string, string> = {};
|
|
96
|
+
const order: string[] = [];
|
|
97
|
+
for (let i = template ? 1 : 0; i < tokens.length; i++) {
|
|
98
|
+
const kv = splitKeyValueToken(tokens[i]);
|
|
99
|
+
if (kv) {
|
|
100
|
+
if (!(kv.key in params)) order.push(kv.key);
|
|
101
|
+
params[kv.key] = kv.value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!(key in params)) order.push(key);
|
|
105
|
+
params[key] = value;
|
|
106
|
+
|
|
107
|
+
const parts = template ? [template] : [];
|
|
108
|
+
for (const k of order) parts.push(`${k}=${quoteAttrValue(params[k])}`);
|
|
109
|
+
return `{[${parts.join(' ')}]}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A patch to a media clip; numeric values are seconds, `null` removes the key. */
|
|
113
|
+
export interface MediaClipPatch {
|
|
114
|
+
startAt?: number | null;
|
|
115
|
+
clipStart?: number | null;
|
|
116
|
+
clipEnd?: number | null;
|
|
117
|
+
spillover?: boolean | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Patch the `{[audio …]}` / `{[video …]}` annotation at 1-based `line`.
|
|
122
|
+
* Preserves the template name and any params not in the patch. Returns the
|
|
123
|
+
* new full source, or null when the line isn't a media annotation.
|
|
124
|
+
*/
|
|
125
|
+
export function setMediaClipInSource(
|
|
126
|
+
source: string,
|
|
127
|
+
line: number,
|
|
128
|
+
patch: MediaClipPatch,
|
|
129
|
+
): string | null {
|
|
130
|
+
const lines = source.split('\n');
|
|
131
|
+
const idx = line - 1;
|
|
132
|
+
if (idx < 0 || idx >= lines.length) return null;
|
|
133
|
+
const original = lines[idx];
|
|
134
|
+
const m = matchTrailingTemplateAnnotation(original);
|
|
135
|
+
if (!m) return null;
|
|
136
|
+
|
|
137
|
+
const tokens = tokenizeAttrTokens(m.inner.trim());
|
|
138
|
+
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
139
|
+
const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
|
|
140
|
+
if (!template || !['audio', 'video', 'media'].includes(template)) return null;
|
|
141
|
+
|
|
142
|
+
const params: Record<string, string> = {};
|
|
143
|
+
const order: string[] = [];
|
|
144
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
145
|
+
const kv = splitKeyValueToken(tokens[i]);
|
|
146
|
+
if (kv) {
|
|
147
|
+
if (!(kv.key in params)) order.push(kv.key);
|
|
148
|
+
params[kv.key] = kv.value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const apply = (key: string, value: string | null) => {
|
|
153
|
+
if (value === null) {
|
|
154
|
+
delete params[key];
|
|
155
|
+
const i = order.indexOf(key);
|
|
156
|
+
if (i >= 0) order.splice(i, 1);
|
|
157
|
+
} else {
|
|
158
|
+
if (!(key in params)) order.push(key);
|
|
159
|
+
params[key] = value;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
if (patch.startAt !== undefined)
|
|
163
|
+
apply('startAt', patch.startAt == null ? null : formatSeconds(patch.startAt));
|
|
164
|
+
if (patch.clipStart !== undefined)
|
|
165
|
+
apply('clipStart', patch.clipStart == null ? null : formatSeconds(patch.clipStart));
|
|
166
|
+
if (patch.clipEnd !== undefined)
|
|
167
|
+
apply('clipEnd', patch.clipEnd == null ? null : formatSeconds(patch.clipEnd));
|
|
168
|
+
if (patch.spillover !== undefined) apply('spillover', patch.spillover ? 'true' : null);
|
|
169
|
+
|
|
170
|
+
const parts = [template, ...order.map((k) => `${k}=${quoteAttrValue(params[k])}`)];
|
|
171
|
+
const annotation = `{[${parts.join(' ')}]}`;
|
|
172
|
+
const prefix = original.slice(0, m.index).replace(/\s+$/, '');
|
|
173
|
+
lines[idx] = prefix ? `${prefix} ${annotation}` : annotation;
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A timed media clip's identity for (re)writing its annotation. */
|
|
178
|
+
export interface ClipSpec {
|
|
179
|
+
kind: 'audio' | 'video';
|
|
180
|
+
src: string;
|
|
181
|
+
clipStart?: number;
|
|
182
|
+
clipEnd?: number;
|
|
183
|
+
spillover?: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Build a `{[audio|video src=… startAt=… clipEnd=…]}` annotation string. */
|
|
187
|
+
export function buildClipAnnotation(spec: ClipSpec, startAt: number): string {
|
|
188
|
+
const parts: string[] = [spec.kind, `src=${quoteAttrValue(spec.src)}`];
|
|
189
|
+
if (startAt > 0) parts.push(`startAt=${formatSeconds(startAt)}`);
|
|
190
|
+
if (spec.clipStart != null && spec.clipStart > 0) {
|
|
191
|
+
parts.push(`clipStart=${formatSeconds(spec.clipStart)}`);
|
|
192
|
+
}
|
|
193
|
+
if (spec.clipEnd != null) parts.push(`clipEnd=${formatSeconds(spec.clipEnd)}`);
|
|
194
|
+
if (spec.spillover) parts.push('spillover=true');
|
|
195
|
+
return `{[${parts.join(' ')}]}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Index of the heading line at/above `idx` (−1 for the preamble). */
|
|
199
|
+
function headingIndexAbove(lines: string[], idx: number): number {
|
|
200
|
+
for (let i = Math.min(idx, lines.length - 1); i >= 0; i--) {
|
|
201
|
+
if (/^#{1,6}\s/.test(lines[i])) return i;
|
|
202
|
+
}
|
|
203
|
+
return -1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Place a timed clip into the block whose heading is at 1-based
|
|
208
|
+
* `targetHeadingLine`, written as a `{[…]}` annotation. The clip's current
|
|
209
|
+
* authoring line at 1-based `fromLine` (a media embed or an existing
|
|
210
|
+
* annotation) is removed; when the target is the same block the line is
|
|
211
|
+
* rewritten in place. Returns the new source, or null on bad input.
|
|
212
|
+
*
|
|
213
|
+
* This is what lets a clip be dragged from one block into another on the
|
|
214
|
+
* timeline: its representation relocates to the target block's body.
|
|
215
|
+
*/
|
|
216
|
+
export function placeClipInBlock(
|
|
217
|
+
source: string,
|
|
218
|
+
fromLine: number,
|
|
219
|
+
targetHeadingLine: number,
|
|
220
|
+
spec: ClipSpec,
|
|
221
|
+
startAt: number,
|
|
222
|
+
): string | null {
|
|
223
|
+
const lines = source.split('\n');
|
|
224
|
+
const fromIdx = fromLine - 1;
|
|
225
|
+
if (fromIdx < 0 || fromIdx >= lines.length) return null;
|
|
226
|
+
const targetIdx0 = targetHeadingLine - 1;
|
|
227
|
+
if (targetIdx0 < 0 || targetIdx0 >= lines.length) return null;
|
|
228
|
+
|
|
229
|
+
const annotation = buildClipAnnotation(spec, Math.max(0, startAt));
|
|
230
|
+
|
|
231
|
+
// Same block → rewrite the line in place (preserves position).
|
|
232
|
+
if (headingIndexAbove(lines, fromIdx) === targetIdx0) {
|
|
233
|
+
lines[fromIdx] = annotation;
|
|
234
|
+
return lines.join('\n');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Cross-block → remove the source line and insert into the target block,
|
|
238
|
+
// right after its heading on its own paragraph.
|
|
239
|
+
lines.splice(fromIdx, 1);
|
|
240
|
+
let targetIdx = targetIdx0;
|
|
241
|
+
if (fromIdx < targetIdx) targetIdx -= 1;
|
|
242
|
+
lines.splice(targetIdx + 1, 0, '', annotation);
|
|
243
|
+
return lines.join('\n');
|
|
244
|
+
}
|