@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.
Files changed (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16875 -6883
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +169 -13
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +2 -14
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +8 -1
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +23 -1
  164. package/src/useTimelineClock.ts +76 -0
  165. 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: min(1200px, 100%);
113
- height: min(800px, 100%);
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);
@@ -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
+ }