@bendyline/squisq-editor-react 1.6.2 → 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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,233 @@
1
+ import { Extension } from '@tiptap/core';
2
+ import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
3
+ import { Decoration, DecorationSet, type EditorView as ProseMirrorView } from '@tiptap/pm/view';
4
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
5
+
6
+ export const BLOCK_TAG_SELECTED_CLASS = 'squisq-block-tags--selected';
7
+ export const BLOCK_TAG_HOVERED_CLASS = 'squisq-block-tags--hovered';
8
+
9
+ export interface BlockTagActivityPluginState {
10
+ selectedPosition: number | null;
11
+ hoveredPosition: number | null;
12
+ decorations: DecorationSet;
13
+ }
14
+
15
+ interface BlockTagHoverMeta {
16
+ hoveredPosition: number | null;
17
+ }
18
+
19
+ export const BLOCK_TAG_ACTIVITY_KEY = new PluginKey<BlockTagActivityPluginState>(
20
+ 'squisq-block-tag-activity',
21
+ );
22
+
23
+ function isHeadingElement(element: Element): element is HTMLElement {
24
+ return /^H[1-6]$/.test(element.tagName);
25
+ }
26
+
27
+ /**
28
+ * Returns the heading position for the block owning a document position.
29
+ * A block begins at a top-level heading and continues until the next heading.
30
+ */
31
+ export function findOwningHeadingPosition(doc: ProseMirrorNode, position: number): number | null {
32
+ let offset = 0;
33
+ let owningPosition: number | null = null;
34
+
35
+ for (let index = 0; index < doc.childCount && offset <= position; index += 1) {
36
+ const node = doc.child(index);
37
+ if (node.type.name === 'heading') owningPosition = offset;
38
+ offset += node.nodeSize;
39
+ }
40
+
41
+ return owningPosition;
42
+ }
43
+
44
+ /** Find the preceding top-level heading for a DOM node under ProseMirror. */
45
+ export function findOwningHeadingElement(
46
+ editorDom: HTMLElement,
47
+ target: EventTarget | null,
48
+ ): HTMLElement | null {
49
+ if (!(target instanceof Node)) return null;
50
+
51
+ let element =
52
+ target.nodeType === Node.ELEMENT_NODE
53
+ ? (target as HTMLElement)
54
+ : (target.parentElement as HTMLElement | null);
55
+ if (!element || element === editorDom || !editorDom.contains(element)) return null;
56
+
57
+ while (element.parentElement && element.parentElement !== editorDom) {
58
+ element = element.parentElement;
59
+ }
60
+ if (element.parentElement !== editorDom) return null;
61
+
62
+ let candidate: Element | null = element;
63
+ while (candidate && !isHeadingElement(candidate)) {
64
+ candidate = candidate.previousElementSibling;
65
+ }
66
+ return candidate && isHeadingElement(candidate) ? candidate : null;
67
+ }
68
+
69
+ /**
70
+ * Resolve blank canvas space to the heading-defined block at that vertical
71
+ * position. Margins between top-level nodes are not owned by either node in
72
+ * the DOM, so pointer events there target the ProseMirror root. Treating the
73
+ * root as "no block" makes active-only tags disappear while the pointer
74
+ * crosses ordinary paragraph/list spacing.
75
+ */
76
+ export function findOwningHeadingElementAtPoint(
77
+ editorDom: HTMLElement,
78
+ target: EventTarget | null,
79
+ clientY: number,
80
+ ): HTMLElement | null {
81
+ const direct = findOwningHeadingElement(editorDom, target);
82
+ if (direct) return direct;
83
+ if (!(target instanceof Node) || !editorDom.contains(target) || !Number.isFinite(clientY)) {
84
+ return null;
85
+ }
86
+
87
+ const children = Array.from(editorDom.children);
88
+ const lastChild = children[children.length - 1];
89
+ if (!lastChild || clientY > lastChild.getBoundingClientRect().bottom) return null;
90
+
91
+ let owningHeading: HTMLElement | null = null;
92
+ for (const child of children) {
93
+ if (child.getBoundingClientRect().top > clientY) break;
94
+ if (isHeadingElement(child)) owningHeading = child;
95
+ }
96
+ return owningHeading;
97
+ }
98
+
99
+ function validHeadingPosition(doc: ProseMirrorNode, position: number | null): number | null {
100
+ if (position === null) return null;
101
+ return doc.nodeAt(position)?.type.name === 'heading' ? position : null;
102
+ }
103
+
104
+ function buildDecorations(
105
+ doc: ProseMirrorNode,
106
+ selectedPosition: number | null,
107
+ hoveredPosition: number | null,
108
+ ): DecorationSet {
109
+ const classesByPosition = new Map<number, string[]>();
110
+
111
+ if (selectedPosition !== null) {
112
+ classesByPosition.set(selectedPosition, [BLOCK_TAG_SELECTED_CLASS]);
113
+ }
114
+ if (hoveredPosition !== null) {
115
+ const classes = classesByPosition.get(hoveredPosition) ?? [];
116
+ classes.push(BLOCK_TAG_HOVERED_CLASS);
117
+ classesByPosition.set(hoveredPosition, classes);
118
+ }
119
+
120
+ const decorations: Decoration[] = [];
121
+ for (const [position, classes] of classesByPosition) {
122
+ const node = doc.nodeAt(position);
123
+ if (!node || node.type.name !== 'heading') continue;
124
+ decorations.push(
125
+ Decoration.node(position, position + node.nodeSize, { class: classes.join(' ') }),
126
+ );
127
+ }
128
+
129
+ return decorations.length > 0 ? DecorationSet.create(doc, decorations) : DecorationSet.empty;
130
+ }
131
+
132
+ function createPluginState(
133
+ doc: ProseMirrorNode,
134
+ selectionFrom: number,
135
+ hoveredPosition: number | null,
136
+ ): BlockTagActivityPluginState {
137
+ const selectedPosition = findOwningHeadingPosition(doc, selectionFrom);
138
+ const validHoveredPosition = validHeadingPosition(doc, hoveredPosition);
139
+ return {
140
+ selectedPosition,
141
+ hoveredPosition: validHoveredPosition,
142
+ decorations: buildDecorations(doc, selectedPosition, validHoveredPosition),
143
+ };
144
+ }
145
+
146
+ function nextHoveredPosition(
147
+ transaction: Transaction,
148
+ previousPosition: number | null,
149
+ doc: ProseMirrorNode,
150
+ ): number | null {
151
+ const meta = transaction.getMeta(BLOCK_TAG_ACTIVITY_KEY) as BlockTagHoverMeta | undefined;
152
+ if (meta !== undefined) return validHeadingPosition(doc, meta.hoveredPosition);
153
+ if (!transaction.docChanged || previousPosition === null) return previousPosition;
154
+
155
+ const mapped = transaction.mapping.mapResult(previousPosition, 1);
156
+ return mapped.deleted ? null : validHeadingPosition(doc, mapped.pos);
157
+ }
158
+
159
+ function headingPositionForTarget(
160
+ view: ProseMirrorView,
161
+ target: EventTarget | null,
162
+ clientY: number,
163
+ ): number | null {
164
+ const heading = findOwningHeadingElementAtPoint(view.dom, target, clientY);
165
+ if (!heading) return null;
166
+
167
+ try {
168
+ const positionInsideHeading = view.posAtDOM(heading, 0);
169
+ return findOwningHeadingPosition(view.state.doc, positionInsideHeading);
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ function setHoveredPosition(view: ProseMirrorView, hoveredPosition: number | null): void {
176
+ const current = BLOCK_TAG_ACTIVITY_KEY.getState(view.state)?.hoveredPosition ?? null;
177
+ if (current === hoveredPosition) return;
178
+ view.dispatch(
179
+ view.state.tr
180
+ .setMeta(BLOCK_TAG_ACTIVITY_KEY, { hoveredPosition } satisfies BlockTagHoverMeta)
181
+ .setMeta('addToHistory', false),
182
+ );
183
+ }
184
+
185
+ /**
186
+ * ProseMirror-owned block activity decorations. Using node decorations keeps
187
+ * the classes stable when ProseMirror reconciles its managed heading DOM.
188
+ */
189
+ export const BlockTagActivityExtension = Extension.create({
190
+ name: 'squisqBlockTagActivity',
191
+
192
+ addProseMirrorPlugins() {
193
+ return [
194
+ new Plugin<BlockTagActivityPluginState>({
195
+ key: BLOCK_TAG_ACTIVITY_KEY,
196
+ state: {
197
+ init: (_config, state) => createPluginState(state.doc, state.selection.from, null),
198
+ apply: (transaction, previous, _oldState, newState) =>
199
+ createPluginState(
200
+ newState.doc,
201
+ newState.selection.from,
202
+ nextHoveredPosition(transaction, previous.hoveredPosition, newState.doc),
203
+ ),
204
+ },
205
+ props: {
206
+ decorations(state) {
207
+ return this.getState(state)?.decorations ?? DecorationSet.empty;
208
+ },
209
+ handleDOMEvents: {
210
+ mouseover(view, event) {
211
+ setHoveredPosition(view, headingPositionForTarget(view, event.target, event.clientY));
212
+ return false;
213
+ },
214
+ mousemove(view, event) {
215
+ // `mouseover` covers real document nodes. Only track continuous
216
+ // movement while the root itself owns the pointer (the blank
217
+ // margin/gutter case), avoiding geometry reads over text.
218
+ if (event.target !== view.dom) return false;
219
+ setHoveredPosition(view, headingPositionForTarget(view, event.target, event.clientY));
220
+ return false;
221
+ },
222
+ mouseleave(view) {
223
+ setHoveredPosition(view, null);
224
+ return false;
225
+ },
226
+ },
227
+ },
228
+ }),
229
+ ];
230
+ },
231
+ });
232
+
233
+ export default BlockTagActivityExtension;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Build the text-first Document-mode projection for a transformed Doc.
3
+ *
4
+ * Transform-generated blocks are programmatic template inputs: unlike blocks
5
+ * parsed from Markdown, they do not carry `sourceHeading` / `contents` nodes.
6
+ * `docToMarkdown()` intentionally omits such blocks because its primary job is
7
+ * lossless authoring round-trips. For a summarized preview we instead give the
8
+ * generated blocks a temporary, readable authoring shape before serializing.
9
+ */
10
+
11
+ import { docToMarkdown } from '@bendyline/squisq/doc';
12
+ import { stringifyMarkdown } from '@bendyline/squisq/markdown';
13
+ import type { MarkdownHeading, MarkdownParagraph } from '@bendyline/squisq/markdown';
14
+ import type { Block, Doc } from '@bendyline/squisq/schemas';
15
+
16
+ function textParagraph(value: string): MarkdownParagraph {
17
+ return { type: 'paragraph', children: [{ type: 'text', value }] };
18
+ }
19
+
20
+ function textHeading(value: string, depth: 1 | 2 = 2): MarkdownHeading {
21
+ return { type: 'heading', depth, children: [{ type: 'text', value }] };
22
+ }
23
+
24
+ function stringValue(record: Record<string, unknown>, key: string): string | undefined {
25
+ const value = record[key];
26
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
27
+ }
28
+
29
+ function stringList(record: Record<string, unknown>, key: string): string[] {
30
+ const value = record[key];
31
+ if (!Array.isArray(value)) return [];
32
+ return value.filter((item): item is string => typeof item === 'string' && !!item.trim());
33
+ }
34
+
35
+ interface ReadableBlockContent {
36
+ title?: string;
37
+ body: string[];
38
+ }
39
+
40
+ /** Extract the reader-facing copy carried by a programmatic template block. */
41
+ function readableTemplateContent(block: Block): ReadableBlockContent {
42
+ const value = block as unknown as Record<string, unknown>;
43
+ const template = stringValue(value, 'template');
44
+ const explicitTitle = stringValue(value, 'title');
45
+
46
+ switch (template) {
47
+ case 'statHighlight':
48
+ return {
49
+ title: stringValue(value, 'stat') ?? explicitTitle,
50
+ body: [stringValue(value, 'description')].filter(Boolean) as string[],
51
+ };
52
+ case 'factCard':
53
+ return {
54
+ title: stringValue(value, 'fact') ?? explicitTitle,
55
+ body: [stringValue(value, 'explanation')].filter(Boolean) as string[],
56
+ };
57
+ case 'quote':
58
+ return {
59
+ title: stringValue(value, 'quote') ?? explicitTitle,
60
+ body: [stringValue(value, 'attribution')].filter(Boolean) as string[],
61
+ };
62
+ case 'fullBleedQuote':
63
+ case 'pullQuote':
64
+ return {
65
+ title: stringValue(value, 'text') ?? stringValue(value, 'quote') ?? explicitTitle,
66
+ body: [stringValue(value, 'attribution')].filter(Boolean) as string[],
67
+ };
68
+ case 'dateEvent':
69
+ return {
70
+ title: stringValue(value, 'date') ?? explicitTitle,
71
+ body: [stringValue(value, 'description')].filter(Boolean) as string[],
72
+ };
73
+ case 'definitionCard':
74
+ return {
75
+ title: stringValue(value, 'term') ?? explicitTitle,
76
+ body: [stringValue(value, 'definition'), stringValue(value, 'origin')].filter(
77
+ Boolean,
78
+ ) as string[],
79
+ };
80
+ case 'twoColumn':
81
+ return {
82
+ title: stringValue(value, 'header') ?? explicitTitle ?? 'Comparison',
83
+ body: [stringValue(value, 'left'), stringValue(value, 'right')].filter(Boolean) as string[],
84
+ };
85
+ case 'list':
86
+ return {
87
+ title: explicitTitle ?? 'Highlights',
88
+ body: stringList(value, 'items'),
89
+ };
90
+ case 'imageWithCaption':
91
+ return {
92
+ title: stringValue(value, 'caption') ?? stringValue(value, 'imageAlt') ?? explicitTitle,
93
+ body: [],
94
+ };
95
+ default:
96
+ return {
97
+ title: explicitTitle,
98
+ body: [
99
+ stringValue(value, 'body'),
100
+ stringValue(value, 'description'),
101
+ stringValue(value, 'text'),
102
+ ].filter(Boolean) as string[],
103
+ };
104
+ }
105
+ }
106
+
107
+ function hasAuthoredShape(block: Block): boolean {
108
+ return !!(
109
+ block.sourceHeading ||
110
+ block.standaloneAnnotation ||
111
+ (block.contents && block.contents.length > 0)
112
+ );
113
+ }
114
+
115
+ function makeReadableBlock(block: Block): Block {
116
+ const children = block.children?.map(makeReadableBlock);
117
+ if (hasAuthoredShape(block)) {
118
+ return children === block.children ? block : { ...block, children };
119
+ }
120
+
121
+ const readable = readableTemplateContent(block);
122
+ return {
123
+ ...block,
124
+ // Document mode is intentionally text-first. Removing the template from
125
+ // this temporary projection prevents docToMarkdown from re-adding a
126
+ // presentation annotation to the synthesized heading.
127
+ template: undefined,
128
+ sourceHeading: readable.title ? textHeading(readable.title) : undefined,
129
+ contents: readable.body.map(textParagraph),
130
+ children,
131
+ };
132
+ }
133
+
134
+ function documentTitle(doc: Doc): string | undefined {
135
+ if (doc.startBlock?.title?.trim()) return doc.startBlock.title.trim();
136
+ const title = doc.frontmatter?.title;
137
+ return typeof title === 'string' && title.trim() ? title.trim() : undefined;
138
+ }
139
+
140
+ function hasLevelOneHeading(blocks: Block[]): boolean {
141
+ return blocks.some(
142
+ (block) =>
143
+ block.sourceHeading?.depth === 1 ||
144
+ (block.children ? hasLevelOneHeading(block.children) : false),
145
+ );
146
+ }
147
+
148
+ /** Serialize a transformed Doc into readable Markdown for Document mode. */
149
+ export function buildDocumentPreviewMarkdown(doc: Doc): string {
150
+ const blocks = doc.blocks.map(makeReadableBlock);
151
+ const title = documentTitle(doc);
152
+ const titledBlocks =
153
+ title && !hasLevelOneHeading(blocks)
154
+ ? [
155
+ {
156
+ id: 'transform-document-title',
157
+ startTime: 0,
158
+ duration: 0,
159
+ audioSegment: 0,
160
+ sourceHeading: textHeading(title, 1),
161
+ contents: [],
162
+ } satisfies Block,
163
+ ...blocks,
164
+ ]
165
+ : blocks;
166
+
167
+ return stringifyMarkdown(docToMarkdown({ ...doc, blocks: titledBlocks }));
168
+ }
@@ -11,7 +11,7 @@
11
11
  * 4. Synthesize a dummy audio segment for timer-based playback
12
12
  */
13
13
 
14
- import { flattenRenderableBlocks, hasTemplate } from '@bendyline/squisq/doc';
14
+ import { deriveTemplateInputs, flattenRenderableBlocks, hasTemplate } from '@bendyline/squisq/doc';
15
15
  import { extractPlainText, KNOWN_BLOCK_META_KEYS } from '@bendyline/squisq/markdown';
16
16
  import { getChildren } from '@bendyline/squisq/markdown';
17
17
  import { iconMarker } from '@bendyline/squisq/icon-marker';
@@ -158,7 +158,12 @@ function getTemplateDefaults(
158
158
 
159
159
  switch (templateName) {
160
160
  case 'statHighlight':
161
- return { stat: headingText, description: body || headingText };
161
+ return (
162
+ deriveTemplateInputs(templateName, headingText, block.contents) ?? {
163
+ stat: headingText,
164
+ description: body || headingText,
165
+ }
166
+ );
162
167
  case 'quote':
163
168
  case 'fullBleedQuote':
164
169
  case 'pullQuote':
@@ -381,18 +386,20 @@ export function buildPreviewDoc(doc: Doc): Doc {
381
386
  t += slide.duration as number;
382
387
  }
383
388
 
389
+ const audio =
390
+ doc.audio?.segments?.length > 0
391
+ ? doc.audio
392
+ : {
393
+ segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
394
+ };
395
+
384
396
  return {
385
- articleId: doc.articleId,
397
+ // Preserve document-wide capabilities (custom themes, persistent layers,
398
+ // scheduled media, frontmatter, captions, and future schema fields).
399
+ // Preview preparation should replace only the slide/timing projection.
400
+ ...doc,
386
401
  duration: t,
387
402
  blocks: slides as unknown as Block[],
388
- audio: {
389
- segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
390
- },
391
- ...(doc.captions ? { captions: doc.captions } : {}),
392
- ...(doc.startBlock ? { startBlock: doc.startBlock } : {}),
393
- ...(doc.themeId ? { themeId: doc.themeId } : {}),
394
- // Custom templates ride along so `useDocPlayback` can merge them
395
- // onto the registry before expanding slides.
396
- ...(doc.customTemplates ? { customTemplates: doc.customTemplates } : {}),
403
+ audio,
397
404
  };
398
405
  }
@@ -56,7 +56,7 @@ export interface CodeContext {
56
56
  /** Line-anchored sections. Array order is preserved for equal lines. */
57
57
  sections?: CodeContextSection[];
58
58
  /**
59
- * Extra URI schemes section links may use (e.g. `['gezel-nav']`).
59
+ * Extra URI schemes section links may use (e.g. `['workspace-nav']`).
60
60
  * http/https/mailto/tel are always allowed; executable schemes
61
61
  * (javascript:, data:) are never allowed regardless.
62
62
  */
@@ -47,7 +47,7 @@ type Selection =
47
47
 
48
48
  export function CustomLayoutManager({ onClose }: CustomLayoutManagerProps) {
49
49
  const { docTemplates, onDocTemplatesChange } = useDocCustomTemplates();
50
- const { mediaProvider } = useEditorContext();
50
+ const { mediaProvider, colorScheme } = useEditorContext();
51
51
 
52
52
  // Close on Escape, mirroring the other editor dialogs.
53
53
  useEffect(() => {
@@ -60,7 +60,8 @@ export function CustomLayoutManager({ onClose }: CustomLayoutManagerProps) {
60
60
 
61
61
  return createPortal(
62
62
  <div
63
- className="squisq-layout-manager-overlay"
63
+ className="squisq-editor-shell squisq-layout-manager-overlay"
64
+ data-theme={colorScheme}
64
65
  role="dialog"
65
66
  aria-modal="true"
66
67
  aria-label="Custom layouts"
@@ -34,6 +34,7 @@ import {
34
34
  listLibraryTemplates,
35
35
  saveLibraryTemplate as saveLibraryTemplateRaw,
36
36
  deleteLibraryTemplate as deleteLibraryTemplateRaw,
37
+ LIBRARY_STORAGE_KEY,
37
38
  } from './library';
38
39
 
39
40
  export interface CustomTemplateContextValue {
@@ -99,6 +100,11 @@ export function CustomTemplateProvider({
99
100
  // re-reads, which is enough for the single-tab common case.
100
101
  useEffect(() => {
101
102
  setLibraryTemplates(listLibraryTemplates());
103
+ const onStorage = (event: StorageEvent) => {
104
+ if (event.key === LIBRARY_STORAGE_KEY) setLibraryTemplates(listLibraryTemplates());
105
+ };
106
+ window.addEventListener('storage', onStorage);
107
+ return () => window.removeEventListener('storage', onStorage);
102
108
  }, []);
103
109
 
104
110
  const upsertDocTemplate = useCallback(
@@ -77,6 +77,8 @@ interface TemplateDesignerProps {
77
77
  * preview. Omit to disable media in the bin.
78
78
  */
79
79
  mediaProvider?: MediaProvider | null;
80
+ /** Editor chrome scheme, copied onto the portal root for theme inheritance. */
81
+ colorScheme?: 'light' | 'dark';
80
82
  }
81
83
 
82
84
  const DESIGN_CANVAS = { width: 1920, height: 1080 };
@@ -112,6 +114,7 @@ export function TemplateDesigner({
112
114
  embedded = false,
113
115
  primarySaveLabel = 'Save to this doc',
114
116
  mediaProvider = null,
117
+ colorScheme = 'light',
115
118
  }: TemplateDesignerProps) {
116
119
  const [name, setName] = useState(initial?.name ?? '');
117
120
  const [label, setLabel] = useState(initial?.label ?? '');
@@ -147,9 +150,8 @@ export function TemplateDesigner({
147
150
  );
148
151
 
149
152
  // Build the toolset once. Each bin entry — placeholder token or shape —
150
- // gets a click-to-place tool; SelectTool is the singleton from
151
- // scene/tools/SelectTool.ts. Tools are factories so each has its own
152
- // state-free closure.
153
+ // gets a click-to-place tool. Gesture state is supplied by each Scene,
154
+ // so the reusable tool definitions remain instance-safe.
153
155
  const tools: SceneTool[] = useMemo(
154
156
  () => [
155
157
  SelectTool,
@@ -414,7 +416,8 @@ export function TemplateDesigner({
414
416
  // Standalone: full-screen modal over a click-to-dismiss backdrop.
415
417
  return createPortal(
416
418
  <div
417
- className="squisq-template-designer-overlay"
419
+ className="squisq-editor-shell squisq-template-designer-overlay"
420
+ data-theme={colorScheme}
418
421
  role="dialog"
419
422
  aria-modal="true"
420
423
  aria-label="Custom layout designer"
@@ -85,4 +85,12 @@ describe('library', () => {
85
85
  saveLibraryTemplate(defn('a'));
86
86
  expect(listLibraryTemplates().map((t) => t.name)).toEqual(['a']);
87
87
  });
88
+
89
+ it('filters structurally invalid array entries instead of crashing sort', () => {
90
+ window.localStorage.setItem(
91
+ LIBRARY_STORAGE_KEY,
92
+ JSON.stringify({ version: 1, templates: [null, {}, defn('valid')] }),
93
+ );
94
+ expect(listLibraryTemplates().map((template) => template.name)).toEqual(['valid']);
95
+ });
88
96
  });
@@ -34,6 +34,19 @@ describe('applyCommand', () => {
34
34
  expect(after[0].position.height).toBe(80);
35
35
  });
36
36
 
37
+ it('resizeLayer applies an optional north/west origin atomically', () => {
38
+ const before: Layer[] = [rect('a', 10, 20, 100, 50)];
39
+ const after = applyCommand(before, {
40
+ kind: 'resizeLayer',
41
+ id: 'a',
42
+ x: 0,
43
+ y: 5,
44
+ width: 110,
45
+ height: 65,
46
+ });
47
+ expect(after[0].position).toMatchObject({ x: 0, y: 5, width: 110, height: 65 });
48
+ });
49
+
37
50
  it('addLayer appends', () => {
38
51
  const before: Layer[] = [rect('a', 0, 0)];
39
52
  const newLayer = rect('b', 100, 100);