@bendyline/squisq-editor-react 1.6.2 → 2.0.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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  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 +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  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 +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. package/src/diagram/useDiagramData.ts +0 -126
@@ -8,8 +8,11 @@
8
8
  * in sync.
9
9
  */
10
10
 
11
- import { useState, useEffect } from 'react';
11
+ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
12
+ import type { ReactNode } from 'react';
13
+ import { createPortal } from 'react-dom';
12
14
  import { DocPlayer, LinearDocView, useMediaProvider } from '@bendyline/squisq-react';
15
+ import type { AudioController, PlaybackState } from '@bendyline/squisq-react';
13
16
  import type { Doc } from '@bendyline/squisq/schemas';
14
17
  import { applyTransform } from '@bendyline/squisq/transform';
15
18
  import { resolveAudioMapping } from '@bendyline/squisq/doc';
@@ -17,7 +20,10 @@ import type { ContentContainer } from '@bendyline/squisq/storage';
17
20
  import { useEditorContext } from './EditorContext';
18
21
  import { usePreviewSettings } from './PreviewControls';
19
22
  import { buildPreviewDoc } from './buildPreviewDoc';
23
+ import { buildDocumentPreviewMarkdown } from './buildDocumentPreviewMarkdown';
20
24
  import { PlainHtmlPreview } from './PlainHtmlPreview';
25
+ import { TeleprompterView } from './teleprompter/TeleprompterView';
26
+ import { usePresentationModeOptional } from './presentation/PresentationMode';
21
27
 
22
28
  export interface PreviewPanelProps {
23
29
  /** Base path for resolving media URLs in DocPlayer */
@@ -40,8 +46,19 @@ export interface PreviewPanelProps {
40
46
  * are rendered in the main toolbar via PreviewToolbarControls.
41
47
  */
42
48
  export function PreviewPanel({ basePath = '/', className, workspaceContainer }: PreviewPanelProps) {
43
- const { doc, parseError, isParsing, markdownSource, mediaRevision } = useEditorContext();
49
+ const {
50
+ doc,
51
+ parseError,
52
+ isParsing,
53
+ markdownSource,
54
+ mediaRevision,
55
+ setMarkdownSource,
56
+ bumpMediaRevision,
57
+ allowRecording,
58
+ colorScheme,
59
+ } = useEditorContext();
44
60
  const mediaProvider = useMediaProvider();
61
+ const presentation = usePresentationModeOptional();
45
62
  const {
46
63
  activeViewport,
47
64
  activeDisplayMode,
@@ -51,6 +68,13 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
51
68
  activeCaptionsEnabled,
52
69
  activeCoverSlide,
53
70
  } = usePreviewSettings();
71
+ const mainSurfaceRef = useRef<HTMLDivElement>(null);
72
+ const popupSurfaceRef = useRef<HTMLDivElement>(null);
73
+ const [playbackState, setPlaybackState] = useState<PlaybackState | null>(null);
74
+
75
+ const handlePlaybackStateChange = useCallback((next: PlaybackState) => {
76
+ setPlaybackState(next);
77
+ }, []);
54
78
 
55
79
  // Build the player-ready Doc whenever the parsed doc changes.
56
80
  // Transform runs on the ORIGINAL doc (which has block.contents with
@@ -60,40 +84,174 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
60
84
  // Audio mapping is async (reads container files), so we use a two-phase
61
85
  // approach: first build the base doc synchronously, then resolve audio
62
86
  // in an effect and update the state.
63
- const [previewDoc, setPreviewDoc] = useState<Doc | null>(null);
87
+ const [previewProjection, setPreviewProjection] = useState<{
88
+ /** Transformed content model shared by Page and Document. */
89
+ contentDoc: Doc;
90
+ /** Flattened/timed player model shared by Slideshow and Video. */
91
+ playerDoc: Doc;
92
+ } | null>(null);
64
93
 
65
94
  useEffect(() => {
66
95
  if (!doc || !doc.blocks.length) {
67
- setPreviewDoc(null);
96
+ setPreviewProjection(null);
68
97
  return;
69
98
  }
70
99
 
71
- let sourceDoc = doc;
72
- if (activeTransformStyle) {
73
- const result = applyTransform(doc, activeTransformStyle);
74
- sourceDoc = result.doc;
75
- }
100
+ // Audio resolution runs BEFORE the transform: a document-anchored
101
+ // narration take re-times the SOURCE blocks, and the transform's
102
+ // provenance (`sourceStartTime` in seconds) is derived from those
103
+ // times — so summarized slides land where the words are spoken.
104
+ const build = (sourceDoc: Doc) => {
105
+ const contentDoc = activeTransformStyle
106
+ ? applyTransform(sourceDoc, activeTransformStyle).doc
107
+ : sourceDoc;
108
+ return { contentDoc, playerDoc: buildPreviewDoc(contentDoc) };
109
+ };
76
110
 
77
- // If we have a workspace container, try to resolve audio mapping before building preview
78
111
  if (workspaceContainer) {
79
112
  let cancelled = false;
80
- resolveAudioMapping(sourceDoc, workspaceContainer).then((audioDoc) => {
81
- if (!cancelled) {
82
- setPreviewDoc(buildPreviewDoc(audioDoc));
83
- }
84
- });
113
+ resolveAudioMapping(doc, workspaceContainer).then(
114
+ (audioDoc) => {
115
+ if (!cancelled) setPreviewProjection(build(audioDoc));
116
+ },
117
+ () => {
118
+ // The synchronous preview below remains valid when optional audio
119
+ // discovery fails; consume the rejection rather than leaking it.
120
+ },
121
+ );
85
122
  // Set an immediate preview without audio while mapping resolves
86
- setPreviewDoc(buildPreviewDoc(sourceDoc));
123
+ setPreviewProjection(build(doc));
87
124
  return () => {
88
125
  cancelled = true;
89
126
  };
90
127
  }
91
128
 
92
- setPreviewDoc(buildPreviewDoc(sourceDoc));
129
+ setPreviewProjection(build(doc));
93
130
  }, [doc, activeTransformStyle, workspaceContainer]);
94
131
 
95
- // Status overlays for non-ready states
96
- if (isParsing) {
132
+ const previewDoc = previewProjection?.playerDoc ?? null;
133
+ const contentDoc = previewProjection?.contentDoc ?? null;
134
+
135
+ // The popup owns no clock, audio, or transport. It follows the primary
136
+ // player's published state while all actions remain inert, keeping the
137
+ // opener the single source of truth.
138
+ const followerAudioController = useMemo<AudioController | null>(() => {
139
+ if (!playbackState || !previewDoc) return null;
140
+ const noOp = () => Promise.resolve();
141
+ return {
142
+ currentTime: playbackState.currentTime,
143
+ isPlaying: playbackState.isPlaying,
144
+ currentSegment: playbackState.currentSegmentIndex,
145
+ totalDuration: playbackState.totalDuration,
146
+ isEnded:
147
+ playbackState.totalDuration > 0 && playbackState.currentTime >= playbackState.totalDuration,
148
+ isReady: true,
149
+ isAvailable: true,
150
+ play: noOp,
151
+ pause: noOp,
152
+ toggle: noOp,
153
+ seekTo: noOp,
154
+ skipToSegment: noOp,
155
+ restart: noOp,
156
+ };
157
+ }, [playbackState, previewDoc]);
158
+
159
+ const documentMarkdown = useMemo(
160
+ () =>
161
+ activeTransformStyle && contentDoc
162
+ ? buildDocumentPreviewMarkdown(contentDoc)
163
+ : markdownSource,
164
+ [activeTransformStyle, contentDoc, markdownSource],
165
+ );
166
+
167
+ // The public DisplayMode values predate the current labels: raw `page`
168
+ // is the plain Document preview, while raw `linear` is the styled Page view.
169
+ const isDocumentMode = activeDisplayMode === 'page';
170
+ const isPageMode = activeDisplayMode === 'linear';
171
+ const isNarrateMode = activeDisplayMode === 'narrate';
172
+
173
+ useEffect(() => {
174
+ if (presentation?.activeTarget === 'window') return;
175
+ setPlaybackState(null);
176
+ }, [presentation?.activeTarget]);
177
+
178
+ // Page and Document have no playback clock, so mirror their normalized
179
+ // scroll position instead. The popup remains pointer-inert: the editor view
180
+ // is the controller and the audience window follows it.
181
+ useEffect(() => {
182
+ if (presentation?.activeTarget !== 'window' || !presentation.popupRoot) return;
183
+ const mainRoot = mainSurfaceRef.current;
184
+ const followerRoot = popupSurfaceRef.current;
185
+ if (!mainRoot || !followerRoot) return;
186
+
187
+ if (isPageMode) {
188
+ const source = mainRoot.querySelector<HTMLElement>('.squisq-linear');
189
+ const follower = followerRoot.querySelector<HTMLElement>('.squisq-linear');
190
+ if (!source || !follower) return;
191
+ const sync = () => {
192
+ const sourceRange = Math.max(1, source.scrollHeight - source.clientHeight);
193
+ const followerRange = Math.max(0, follower.scrollHeight - follower.clientHeight);
194
+ follower.scrollTop = (source.scrollTop / sourceRange) * followerRange;
195
+ };
196
+ source.addEventListener('scroll', sync, { passive: true });
197
+ sync();
198
+ return () => source.removeEventListener('scroll', sync);
199
+ }
200
+
201
+ if (isDocumentMode) {
202
+ const sourceFrame = mainRoot.querySelector<HTMLIFrameElement>('iframe');
203
+ const followerFrame = followerRoot.querySelector<HTMLIFrameElement>('iframe');
204
+ if (!sourceFrame || !followerFrame) return;
205
+ let detachScroll: (() => void) | null = null;
206
+ const attach = () => {
207
+ detachScroll?.();
208
+ try {
209
+ const sourceWindow = sourceFrame.contentWindow;
210
+ const followerWindow = followerFrame.contentWindow;
211
+ if (!sourceWindow || !followerWindow) return;
212
+ const sync = () => {
213
+ const sourceDoc = sourceWindow.document.documentElement;
214
+ const followerDoc = followerWindow.document.documentElement;
215
+ const sourceRange = Math.max(1, sourceDoc.scrollHeight - sourceWindow.innerHeight);
216
+ const followerRange = Math.max(
217
+ 0,
218
+ followerDoc.scrollHeight - followerWindow.innerHeight,
219
+ );
220
+ followerWindow.scrollTo(0, (sourceWindow.scrollY / sourceRange) * followerRange);
221
+ };
222
+ sourceWindow.addEventListener('scroll', sync, { passive: true });
223
+ detachScroll = () => sourceWindow.removeEventListener('scroll', sync);
224
+ sync();
225
+ } catch {
226
+ // The srcDoc frames are same-origin, but a host may tighten their
227
+ // sandbox. In that case the duplicate content remains useful even
228
+ // though scroll mirroring is unavailable.
229
+ }
230
+ };
231
+ sourceFrame.addEventListener('load', attach);
232
+ followerFrame.addEventListener('load', attach);
233
+ attach();
234
+ return () => {
235
+ sourceFrame.removeEventListener('load', attach);
236
+ followerFrame.removeEventListener('load', attach);
237
+ detachScroll?.();
238
+ };
239
+ }
240
+ }, [
241
+ contentDoc,
242
+ documentMarkdown,
243
+ isDocumentMode,
244
+ isPageMode,
245
+ presentation?.activeTarget,
246
+ presentation?.popupRoot,
247
+ ]);
248
+
249
+ // Status overlays for non-ready states. Narrate is exempt: the
250
+ // teleprompter holds live state (mic capture, an in-flight recording,
251
+ // pacing position) that an unmount would destroy — e.g. saving a take
252
+ // rewrites the markdown, which triggers exactly this reparse. It keeps
253
+ // rendering the last-good doc until the new parse lands.
254
+ if (isParsing && !isNarrateMode) {
97
255
  return (
98
256
  <div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
99
257
  <p>Parsing…</p>
@@ -101,7 +259,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
101
259
  );
102
260
  }
103
261
 
104
- if (parseError) {
262
+ if (parseError && !isNarrateMode) {
105
263
  return (
106
264
  <div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
107
265
  <h3>Parse Error</h3>
@@ -110,15 +268,11 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
110
268
  );
111
269
  }
112
270
 
113
- // The public DisplayMode values predate the current labels: raw `page`
114
- // is the plain Document preview, while raw `linear` is the styled Page view.
115
- const isDocumentMode = activeDisplayMode === 'page';
116
- const isPageMode = activeDisplayMode === 'linear';
117
-
118
- // Document mode renders directly from markdown — it doesn't depend on the
119
- // parsed Doc tree or the player preview build, so let it fall through even
120
- // when those aren't ready yet.
121
- if (!previewDoc && !isDocumentMode) {
271
+ // Document mode renders a text-first markdown projection, and Narrate
272
+ // builds its script from the parsed source doc (showing its own empty
273
+ // state). Neither depends on the player projection, so let them fall
274
+ // through even when that isn't ready yet.
275
+ if (!previewDoc && !isDocumentMode && !isNarrateMode) {
122
276
  return (
123
277
  <div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
124
278
  <p>No content to preview. Start typing in the editor.</p>
@@ -126,63 +280,143 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
126
280
  );
127
281
  }
128
282
 
129
- const fillsContainer = isDocumentMode || isPageMode ? 'stretch' : 'center';
283
+ const fillsContainer = isDocumentMode || isPageMode || isNarrateMode ? 'stretch' : 'center';
284
+ const audienceWindowOpen = presentation?.activeTarget === 'window';
285
+
286
+ const renderSurface = (audience: boolean): ReactNode => {
287
+ if (isDocumentMode) {
288
+ return (
289
+ <PlainHtmlPreview
290
+ markdown={documentMarkdown}
291
+ title={(contentDoc?.frontmatter?.title as string | undefined) ?? undefined}
292
+ mediaProvider={mediaProvider}
293
+ mediaRevision={mediaRevision}
294
+ theme={activeTheme}
295
+ globalKeyboardShortcuts={!audience}
296
+ />
297
+ );
298
+ }
299
+
300
+ if (isNarrateMode) {
301
+ if (audience) return null;
302
+ // Narrate keeps one controller/microphone session in the main view and
303
+ // portals only its display surface to the audience window.
304
+ return (
305
+ <TeleprompterView
306
+ doc={doc}
307
+ theme={activeTheme}
308
+ workspaceContainer={workspaceContainer ?? null}
309
+ basePath={basePath}
310
+ presentationTarget={
311
+ presentation?.activeTarget === 'window' ? presentation.popupRoot : null
312
+ }
313
+ recording={
314
+ allowRecording && mediaProvider
315
+ ? {
316
+ mediaProvider,
317
+ container: workspaceContainer ?? null,
318
+ markdownSource,
319
+ setMarkdownSource,
320
+ bumpMediaRevision,
321
+ }
322
+ : null
323
+ }
324
+ />
325
+ );
326
+ }
327
+
328
+ if (isPageMode) {
329
+ return (
330
+ <LinearDocView
331
+ doc={contentDoc ?? doc!}
332
+ basePath={basePath}
333
+ viewport={activeViewport}
334
+ theme={activeTheme}
335
+ globalKeyboardShortcuts={!audience}
336
+ />
337
+ );
338
+ }
339
+
340
+ if (audience && !followerAudioController) {
341
+ return <div className="squisq-presentation-connecting">Connecting presentation...</div>;
342
+ }
343
+
344
+ const audienceCaptionMode = playbackState?.captionMode;
345
+ return (
346
+ <DocPlayer
347
+ // A style change replaces the slide sequence. Remounting clears state
348
+ // belonging to the previous transformed deck in both render targets.
349
+ key={`${audience ? 'audience' : 'primary'}-${activeTransformStyle || 'none'}`}
350
+ doc={previewDoc!}
351
+ basePath={basePath}
352
+ showControls={!audience}
353
+ muted
354
+ forceViewport={activeViewport}
355
+ displayMode={activeDisplayMode}
356
+ theme={activeTheme}
357
+ captionStyle={audienceCaptionMode === 'social' ? 'social' : activeCaptionStyle}
358
+ captionsEnabled={
359
+ audience && audienceCaptionMode ? audienceCaptionMode !== 'off' : activeCaptionsEnabled
360
+ }
361
+ showCoverSlide={activeCoverSlide}
362
+ coverVisible={audience ? playbackState?.isCoverVisible : undefined}
363
+ audioController={audience ? followerAudioController! : undefined}
364
+ enableSwipe={!audience}
365
+ globalKeyboardShortcuts={!audience}
366
+ onPlaybackStateChange={
367
+ !audience && audienceWindowOpen ? handlePlaybackStateChange : undefined
368
+ }
369
+ />
370
+ );
371
+ };
372
+
373
+ const surfaceStyle = {
374
+ flex: 1,
375
+ display: 'flex',
376
+ alignItems: fillsContainer,
377
+ justifyContent: 'center',
378
+ overflow: 'hidden',
379
+ minHeight: 0,
380
+ } as const;
130
381
 
131
382
  return (
132
- <div
133
- className={`squisq-preview-container ${className || ''}`}
134
- data-testid="preview-panel"
135
- style={{
136
- width: '100%',
137
- height: '100%',
138
- display: 'flex',
139
- flexDirection: 'column',
140
- overflow: 'hidden',
141
- background: 'var(--squisq-bg, #f5f5f5)',
142
- }}
143
- >
144
- {/* Player / Document / Page view */}
383
+ <>
145
384
  <div
146
- className="squisq-preview-player"
385
+ className={`squisq-preview-container ${className || ''}`}
386
+ data-testid="preview-panel"
147
387
  style={{
148
- flex: 1,
388
+ width: '100%',
389
+ height: '100%',
149
390
  display: 'flex',
150
- alignItems: fillsContainer,
151
- justifyContent: 'center',
391
+ flexDirection: 'column',
152
392
  overflow: 'hidden',
153
- minHeight: 0,
393
+ background: 'var(--squisq-bg, #f5f5f5)',
154
394
  }}
155
395
  >
156
- {isDocumentMode ? (
157
- <PlainHtmlPreview
158
- markdown={markdownSource}
159
- title={(doc?.frontmatter?.title as string | undefined) ?? undefined}
160
- mediaProvider={mediaProvider}
161
- mediaRevision={mediaRevision}
162
- theme={activeTheme}
163
- />
164
- ) : isPageMode ? (
165
- <LinearDocView
166
- doc={doc!}
167
- basePath={basePath}
168
- viewport={activeViewport}
169
- theme={activeTheme}
170
- />
171
- ) : (
172
- <DocPlayer
173
- doc={previewDoc!}
174
- basePath={basePath}
175
- showControls
176
- muted
177
- forceViewport={activeViewport}
178
- displayMode={activeDisplayMode}
179
- theme={activeTheme}
180
- captionStyle={activeCaptionStyle}
181
- captionsEnabled={activeCaptionsEnabled}
182
- showCoverSlide={activeCoverSlide}
183
- />
184
- )}
396
+ <div ref={mainSurfaceRef} className="squisq-preview-player" style={surfaceStyle}>
397
+ {renderSurface(false)}
398
+ </div>
185
399
  </div>
186
- </div>
400
+ {audienceWindowOpen && presentation?.popupRoot && !isNarrateMode
401
+ ? createPortal(
402
+ <div
403
+ className="squisq-editor-shell squisq-presentation-window"
404
+ data-theme={colorScheme}
405
+ >
406
+ <div className="squisq-preview-container squisq-presentation-window-preview">
407
+ <div
408
+ ref={popupSurfaceRef}
409
+ className="squisq-preview-player squisq-presentation-window-surface"
410
+ style={surfaceStyle}
411
+ aria-label="Audience presentation"
412
+ >
413
+ {renderSurface(true)}
414
+ </div>
415
+ </div>
416
+ </div>,
417
+ presentation.popupRoot,
418
+ )
419
+ : null}
420
+ </>
187
421
  );
188
422
  }
package/src/RawEditor.tsx CHANGED
@@ -14,6 +14,7 @@ import { getAvailableTemplates } from '@bendyline/squisq/doc';
14
14
  import { suggestIcons, resolveIcon, iconGlyph } from '@bendyline/squisq/icons';
15
15
  import { BLOCK_META_KEY_DESCRIPTORS, tokenizeAttrTokens } from '@bendyline/squisq/markdown';
16
16
  import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
17
+ import { canHandleSquisqMediaDrop, ownsMonacoModel } from './rawEditorIsolation';
17
18
  import { useMonacoLoader } from './useMonacoLoader';
18
19
 
19
20
  // Monaco is loaded lazily through `useMonacoLoader` (see the hook for the
@@ -108,9 +109,13 @@ export function RawEditor({
108
109
  const keyDisposable = useRef<monaco.IDisposable | null>(null);
109
110
  // Ref so the keydown handler always sees the latest callback.
110
111
  const submitOnEnterRef = useRef(submitOnEnter);
112
+ const readOnlyRef = useRef(readOnly);
111
113
  useEffect(() => {
112
114
  submitOnEnterRef.current = submitOnEnter;
113
115
  }, [submitOnEnter]);
116
+ useEffect(() => {
117
+ readOnlyRef.current = readOnly;
118
+ }, [readOnly]);
114
119
  // Ref so the completion provider — registered once at mount — always
115
120
  // sees the latest mentionProvider without needing to unregister.
116
121
  const mentionProviderRef = useRef(mentionProvider);
@@ -194,6 +199,7 @@ export function RawEditor({
194
199
  completionDisposable.current = monaco.languages.registerCompletionItemProvider('markdown', {
195
200
  triggerCharacters: ['['],
196
201
  provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
202
+ if (!ownsMonacoModel(editor, model)) return { suggestions: [] };
197
203
  const lineContent = model.getLineContent(position.lineNumber);
198
204
 
199
205
  // Only trigger inside a heading line that has {[ before the cursor
@@ -248,6 +254,7 @@ export function RawEditor({
248
254
  {
249
255
  triggerCharacters: ['@'],
250
256
  async provideCompletionItems(model, position) {
257
+ if (!ownsMonacoModel(editor, model)) return { suggestions: [] };
251
258
  const provider = mentionProviderRef.current;
252
259
  if (!provider) return { suggestions: [] };
253
260
  const lineContent = model.getLineContent(position.lineNumber);
@@ -306,6 +313,7 @@ export function RawEditor({
306
313
  {
307
314
  triggerCharacters: ['['],
308
315
  provideCompletionItems(model, position) {
316
+ if (!ownsMonacoModel(editor, model)) return { suggestions: [] };
309
317
  const lineContent = model.getLineContent(position.lineNumber);
310
318
  const textBeforeCursor = lineContent.substring(0, position.column - 1);
311
319
  const textAfterCursor = lineContent.substring(position.column - 1);
@@ -388,6 +396,7 @@ export function RawEditor({
388
396
  {
389
397
  triggerCharacters: ['=', ' '],
390
398
  provideCompletionItems(model, position) {
399
+ if (!ownsMonacoModel(editor, model)) return { suggestions: [] };
391
400
  const lineContent = model.getLineContent(position.lineNumber);
392
401
  // Block-meta attributes only mean something on a heading's
393
402
  // template annotation — same gate as the template provider.
@@ -498,12 +507,14 @@ export function RawEditor({
498
507
  const domNode = editor.getDomNode();
499
508
  if (domNode) {
500
509
  const onDragOver = (e: DragEvent) => {
501
- if (e.dataTransfer?.types.includes(SQUISQ_MEDIA_MIME)) {
510
+ const dt = e.dataTransfer;
511
+ if (canHandleSquisqMediaDrop(readOnlyRef.current, dt)) {
502
512
  e.preventDefault();
503
- e.dataTransfer.dropEffect = 'copy';
513
+ dt!.dropEffect = 'copy';
504
514
  }
505
515
  };
506
516
  const onDrop = (e: DragEvent) => {
517
+ if (!canHandleSquisqMediaDrop(readOnlyRef.current, e.dataTransfer)) return;
507
518
  const dt = e.dataTransfer;
508
519
  if (!dt) return;
509
520
  const raw = dt.getData(SQUISQ_MEDIA_MIME);
package/src/StatusBar.tsx CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { useMemo } from 'react';
8
+ import { countBlocks } from '@bendyline/squisq/doc';
8
9
  import { useEditorContext } from './EditorContext';
9
10
 
10
11
  export interface StatusBarProps {
@@ -23,7 +24,7 @@ export function StatusBar({ className }: StatusBarProps) {
23
24
  const chars = markdownSource.length;
24
25
  const words = markdownSource.trim() ? markdownSource.trim().split(/\s+/).length : 0;
25
26
  const lines = markdownSource.split('\n').length;
26
- const blocks = doc?.blocks.length ?? 0;
27
+ const blocks = doc ? countBlocks(doc.blocks) : 0;
27
28
  return { chars, words, lines, blocks };
28
29
  }, [markdownSource, doc]);
29
30
 
@@ -32,7 +33,9 @@ export function StatusBar({ className }: StatusBarProps) {
32
33
  <span className="squisq-status-item">{stats.words} words</span>
33
34
  <span className="squisq-status-item">{stats.chars} chars</span>
34
35
  <span className="squisq-status-item">{stats.lines} lines</span>
35
- <span className="squisq-status-item">{stats.blocks} blocks</span>
36
+ <span className="squisq-status-item">
37
+ {stats.blocks} {stats.blocks === 1 ? 'block' : 'blocks'}
38
+ </span>
36
39
  <span className="squisq-status-spacer" />
37
40
  {isParsing && <span className="squisq-status-item squisq-status-parsing">Parsing…</span>}
38
41
  {parseError && (
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * TemplateAnnotation — Tiptap Heading Extension
3
3
  *
4
- * Extends Tiptap's built-in Heading node to support `data-template` and
5
- * `data-template-params` HTML attributes. These attributes store which block
6
- * template should be used for a heading section.
4
+ * Extends Tiptap's built-in Heading node to support `data-template`,
5
+ * `data-template-params`, and `data-template-empty` HTML attributes. These
6
+ * attributes store the authored template annotation without exposing its raw
7
+ * `{[…]}` syntax in the Write view.
7
8
  *
8
9
  * When present, the heading renders a visible badge (styled CSS chip)
9
10
  * showing the template name, e.g. `[chart]`.
@@ -41,6 +42,14 @@ export const HeadingWithTemplate = Heading.extend({
41
42
  return { 'data-template-params': attributes.dataTemplateParams };
42
43
  },
43
44
  },
45
+ dataTemplateEmpty: {
46
+ default: false,
47
+ parseHTML: (element: HTMLElement) => element.hasAttribute('data-template-empty'),
48
+ renderHTML: (attributes: Record<string, unknown>) => {
49
+ if (!attributes.dataTemplateEmpty) return {};
50
+ return { 'data-template-empty': 'true' };
51
+ },
52
+ },
44
53
  dataBlockAttrs: {
45
54
  default: null,
46
55
  parseHTML: (element: HTMLElement) => element.getAttribute('data-block-attrs') || null,