@bendyline/squisq-editor-react 1.6.1 → 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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  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 +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -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 +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  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 +38 -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__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -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 +312 -6
  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__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -33,7 +33,17 @@ import type { editor as MonacoEditorNs } from 'monaco-editor';
33
33
  import { markdownToTiptap } from './tiptapBridge';
34
34
  import { resolveFileKind } from './fileKind';
35
35
  import { useBlockNavigator } from './useBlockNavigator';
36
- import { sceneTextChannel, type SceneTextHandle } from './scene/text/sceneTextChannel';
36
+ import {
37
+ createSceneTextChannel,
38
+ type SceneTextChannel,
39
+ type SceneTextHandle,
40
+ } from './scene/text/sceneTextChannel';
41
+ import {
42
+ readMonacoScrollRatio,
43
+ readWysiwygScrollRatio,
44
+ restoreMonacoScrollRatio,
45
+ restoreWysiwygScrollRatio,
46
+ } from './editorScrollSync';
37
47
 
38
48
  /** Monaco standalone code editor instance type */
39
49
  type MonacoEditor = MonacoEditorNs.IStandaloneCodeEditor;
@@ -114,6 +124,11 @@ export type LayoutMode = 'document' | 'block' | 'timeline';
114
124
  * colors. `'none'` opts out completely.
115
125
  */
116
126
  export type ThemeInheritance = 'none' | 'fonts' | 'fonts-colors';
127
+ /**
128
+ * When inline block-template tags are shown in the WYSIWYG surface.
129
+ * `'active'` shows tags for the cursor's block and the block under the pointer.
130
+ */
131
+ export type BlockTagVisibility = 'none' | 'active' | 'always';
117
132
  /**
118
133
  * Editor operating mode. `markdown` is the full experience (WYSIWYG +
119
134
  * Preview tabs, formatting toolbar). `code` is a Monaco-only view used
@@ -159,12 +174,11 @@ export interface EditorState {
159
174
  * the toolbar can toggle it at runtime.
160
175
  */
161
176
  outlineVisible: boolean;
177
+ /** When inline block-template tags are shown in the WYSIWYG view. */
178
+ blockTagVisibility: BlockTagVisibility;
162
179
  /**
163
- * Whether inline block-template tags (the chip next to each templated
164
- * heading in the WYSIWYG view, plus the subtle affordance chip on plain
165
- * headings) are currently visible. Initialized from the EditorShell
166
- * `blockTags` prop (default true); the View menu in the toolbar can
167
- * toggle it at runtime.
180
+ * Whether inline block-template tags can currently be visible.
181
+ * Kept for compatibility; prefer {@link blockTagVisibility}.
168
182
  */
169
183
  blockTagsVisible: boolean;
170
184
  /**
@@ -196,6 +210,13 @@ export interface EditorState {
196
210
  * shell.
197
211
  */
198
212
  allowRecording: boolean;
213
+ /**
214
+ * Whether the Narrate (teleprompter) display mode is offered under the
215
+ * Use tab. Orthogonal to `allowRecording`: the teleprompter is useful
216
+ * without any capture (reading for external recording software), and a
217
+ * host may allow the recorder modal but not want a prompter surface.
218
+ */
219
+ allowNarrate: boolean;
199
220
  /**
200
221
  * Document layout mode. `'document'` (default) edits the whole document;
201
222
  * `'block'` activates the block-at-a-time card view. Initialized from the
@@ -256,6 +277,8 @@ export interface EditorActions {
256
277
  setOutlineVisible: (visible: boolean) => void;
257
278
  /** Show or hide inline block-template tags at runtime (driven by the View menu). */
258
279
  setBlockTagsVisible: (visible: boolean) => void;
280
+ /** Choose when inline block-template tags are shown. */
281
+ setBlockTagVisibility: (visibility: BlockTagVisibility) => void;
259
282
  /** Change how much of the active Squisq theme the WYSIWYG surface mirrors. */
260
283
  setThemeInheritance: (mode: ThemeInheritance) => void;
261
284
  /** Insert text at the current cursor position in the active editor */
@@ -292,6 +315,8 @@ export interface EditorContextValue extends EditorState, EditorActions {
292
315
  * buttons apply (`inline` = marks only; `rich` = headings/lists too).
293
316
  */
294
317
  activeSceneText: SceneTextHandle | null;
318
+ /** Instance-owned bridge used by detached scene widget roots. */
319
+ sceneTextChannel: SceneTextChannel;
295
320
  /**
296
321
  * Workspace-scoped `ContentContainer` for this document — the folder
297
322
  * holding the doc, its `_files/` sidecar, sibling documents, and any
@@ -424,6 +449,12 @@ export interface EditorProviderProps {
424
449
  * surfaces where camera/screen prompts would be jarring).
425
450
  */
426
451
  allowRecording?: boolean;
452
+ /**
453
+ * Whether the Narrate (teleprompter) display mode is offered under the
454
+ * Use tab. Defaults to true. When false the mode button is hidden and a
455
+ * frontmatter-forced `display-mode: narrate` clamps back to video.
456
+ */
457
+ allowNarrate?: boolean;
427
458
  /**
428
459
  * File name (e.g. `foo.ts`) or bare extension — used to pick a Monaco
429
460
  * language and decide between markdown vs. code mode.
@@ -447,10 +478,16 @@ export interface EditorProviderProps {
447
478
  */
448
479
  outline?: boolean;
449
480
  /**
450
- * Initial visibility of inline block-template tags on headings.
451
- * Defaults to true. The toolbar's View menu can toggle it at runtime.
481
+ * Legacy initial visibility of inline block-template tags on headings.
482
+ * `true` maps to always visible and `false` maps to hidden. When omitted,
483
+ * {@link blockTagVisibility} defaults to `'active'`.
452
484
  */
453
485
  blockTags?: boolean;
486
+ /**
487
+ * Initial block-tag visibility mode. When set, this takes precedence over
488
+ * the legacy boolean {@link blockTags} prop. Defaults to `'active'`.
489
+ */
490
+ blockTagVisibility?: BlockTagVisibility;
454
491
  /**
455
492
  * Initial value for how much of the active Squisq theme the WYSIWYG
456
493
  * editing surface should mirror. Defaults to `'fonts'` — the
@@ -468,9 +505,9 @@ export interface EditorProviderProps {
468
505
  * Bundled view preferences — a serializable JSON blob covering all
469
506
  * runtime-toggleable view options. When provided, individual values
470
507
  * here override the matching individual props (`inlinePreview`,
471
- * `showStatusBar`, `outline`). Hosts wiring this up typically load
472
- * the blob from their own preferences storage and pair it with
473
- * {@link onViewPreferencesChange}.
508
+ * `showStatusBar`, `outline`, `blockTagVisibility`, `blockTags`). Hosts
509
+ * wiring this up typically load the blob from their own preferences storage
510
+ * and pair it with {@link onViewPreferencesChange}.
474
511
  */
475
512
  viewPreferences?: ViewPreferences;
476
513
  /**
@@ -498,6 +535,8 @@ export interface ViewPreferences {
498
535
  showStatusBar?: boolean;
499
536
  /** Whether inline block-template tags on headings are visible. */
500
537
  blockTags?: boolean;
538
+ /** When inline block-template tags are shown. Takes precedence over `blockTags`. */
539
+ blockTagVisibility?: BlockTagVisibility;
501
540
  /** How much of the active Squisq theme the WYSIWYG surface mirrors. */
502
541
  themeInheritance?: ThemeInheritance;
503
542
  /** Document vs. block-at-a-time layout. */
@@ -511,6 +550,14 @@ export interface ViewPreferences {
511
550
  const DEFAULT_PRUNE_POLICY: PrunePolicy = { type: 'keep-last-n', n: 50 };
512
551
  const DEFAULT_AUTOSAVE_IDLE_MS = 5_000;
513
552
 
553
+ function normalizeBlockTagVisibility(
554
+ value: BlockTagVisibility | boolean | undefined,
555
+ ): BlockTagVisibility {
556
+ if (value === false || value === 'none') return 'none';
557
+ if (value === undefined || value === 'active') return 'active';
558
+ return 'always';
559
+ }
560
+
514
561
  export function EditorProvider({
515
562
  initialMarkdown = '',
516
563
  initialView = 'raw',
@@ -527,12 +574,14 @@ export function EditorProvider({
527
574
  mentionProvider = null,
528
575
  documentLinkProvider = null,
529
576
  allowRecording = true,
577
+ allowNarrate = true,
530
578
  fileName,
531
579
  language,
532
580
  inlinePreview = false,
533
581
  showStatusBar = true,
534
582
  outline = false,
535
- blockTags = true,
583
+ blockTags,
584
+ blockTagVisibility: initialBlockTagVisibility,
536
585
  themeInheritance = 'fonts',
537
586
  layoutMode = 'document',
538
587
  viewPreferences,
@@ -545,7 +594,12 @@ export function EditorProvider({
545
594
  const effectiveInlinePreview = viewPreferences?.inlinePreview ?? inlinePreview;
546
595
  const effectiveShowStatusBar = viewPreferences?.showStatusBar ?? showStatusBar;
547
596
  const effectiveOutline = viewPreferences?.outline ?? outline;
548
- const effectiveBlockTags = viewPreferences?.blockTags ?? blockTags;
597
+ const effectiveBlockTagVisibility = normalizeBlockTagVisibility(
598
+ viewPreferences?.blockTagVisibility ??
599
+ viewPreferences?.blockTags ??
600
+ initialBlockTagVisibility ??
601
+ blockTags,
602
+ );
549
603
  const effectiveThemeInheritance = viewPreferences?.themeInheritance ?? themeInheritance;
550
604
  const effectiveLayoutMode = viewPreferences?.layoutMode ?? layoutMode;
551
605
  // Resolve once per provider mount. Changing fileName/language after mount
@@ -564,12 +618,36 @@ export function EditorProvider({
564
618
  const [activeView, setActiveViewRaw] = useState<EditorView>(
565
619
  editorMode === 'markdown' ? initialView : 'raw',
566
620
  );
621
+ const activeViewRef = useRef(activeView);
622
+ activeViewRef.current = activeView;
623
+ const tiptapEditorRef = useRef<TiptapEditor | null>(null);
624
+ const monacoEditorRef = useRef<MonacoEditor | null>(null);
625
+ const pendingEditorScrollRef = useRef<{ view: 'raw' | 'wysiwyg'; ratio: number } | null>(null);
567
626
  const setActiveView = useCallback(
568
627
  (view: EditorView) => {
569
628
  // In code mode only the raw view is valid. In image mode no text view
570
629
  // is valid at all — ignore any switch attempt.
571
630
  if (editorMode === 'code' && view !== 'raw') return;
572
631
  if (editorMode === 'image') return;
632
+
633
+ const currentView = activeViewRef.current;
634
+ if (view === currentView) return;
635
+
636
+ // Write and Source are separate editor instances and are unmounted as
637
+ // their tabs are hidden. Capture a proportional document position
638
+ // before the current instance disappears, then restore it after the
639
+ // destination has mounted and completed layout.
640
+ let ratio: number | null = null;
641
+ let targetView: 'raw' | 'wysiwyg' | null = null;
642
+ if (currentView === 'raw' && view === 'wysiwyg' && monacoEditorRef.current) {
643
+ ratio = readMonacoScrollRatio(monacoEditorRef.current);
644
+ targetView = 'wysiwyg';
645
+ } else if (currentView === 'wysiwyg' && view === 'raw' && tiptapEditorRef.current) {
646
+ ratio = readWysiwygScrollRatio(tiptapEditorRef.current);
647
+ targetView = 'raw';
648
+ }
649
+ pendingEditorScrollRef.current =
650
+ ratio === null || targetView === null ? null : { view: targetView, ratio };
573
651
  setActiveViewRaw(view);
574
652
  },
575
653
  [editorMode],
@@ -581,29 +659,39 @@ export function EditorProvider({
581
659
  useState<boolean>(effectiveInlinePreview);
582
660
  // Sync visibility when the host changes the prop (e.g., toggle from outside).
583
661
  useEffect(() => {
584
- setInlinePreviewVisibleRaw(inlinePreview);
585
- }, [inlinePreview]);
662
+ if (viewPreferences?.inlinePreview === undefined) setInlinePreviewVisibleRaw(inlinePreview);
663
+ }, [inlinePreview, viewPreferences?.inlinePreview]);
586
664
  const [statusBarVisible, setStatusBarVisibleRaw] = useState<boolean>(effectiveShowStatusBar);
587
665
  useEffect(() => {
588
- setStatusBarVisibleRaw(showStatusBar);
589
- }, [showStatusBar]);
666
+ if (viewPreferences?.showStatusBar === undefined) setStatusBarVisibleRaw(showStatusBar);
667
+ }, [showStatusBar, viewPreferences?.showStatusBar]);
590
668
  const [outlineVisible, setOutlineVisibleRaw] = useState<boolean>(effectiveOutline);
591
669
  useEffect(() => {
592
- setOutlineVisibleRaw(outline);
593
- }, [outline]);
594
- const [blockTagsVisible, setBlockTagsVisibleRaw] = useState<boolean>(effectiveBlockTags);
670
+ if (viewPreferences?.outline === undefined) setOutlineVisibleRaw(outline);
671
+ }, [outline, viewPreferences?.outline]);
672
+ const [blockTagVisibilityState, setBlockTagVisibilityRaw] = useState<BlockTagVisibility>(
673
+ effectiveBlockTagVisibility,
674
+ );
675
+ const blockTagsVisible = blockTagVisibilityState !== 'none';
595
676
  useEffect(() => {
596
- setBlockTagsVisibleRaw(blockTags);
597
- }, [blockTags]);
677
+ if (
678
+ viewPreferences?.blockTagVisibility === undefined &&
679
+ viewPreferences?.blockTags === undefined
680
+ ) {
681
+ setBlockTagVisibilityRaw(normalizeBlockTagVisibility(initialBlockTagVisibility ?? blockTags));
682
+ }
683
+ }, [blockTags, initialBlockTagVisibility, viewPreferences]);
598
684
  const [themeInheritanceState, setThemeInheritanceRaw] =
599
685
  useState<ThemeInheritance>(effectiveThemeInheritance);
600
686
  useEffect(() => {
601
- setThemeInheritanceRaw(themeInheritance);
602
- }, [themeInheritance]);
687
+ if (viewPreferences?.themeInheritance === undefined) {
688
+ setThemeInheritanceRaw(themeInheritance);
689
+ }
690
+ }, [themeInheritance, viewPreferences?.themeInheritance]);
603
691
  const [layoutModeState, setLayoutModeRaw] = useState<LayoutMode>(effectiveLayoutMode);
604
692
  useEffect(() => {
605
- setLayoutModeRaw(layoutMode);
606
- }, [layoutMode]);
693
+ if (viewPreferences?.layoutMode === undefined) setLayoutModeRaw(layoutMode);
694
+ }, [layoutMode, viewPreferences?.layoutMode]);
607
695
  const [imageEditTarget, setImageEditTarget] = useState<string | null>(null);
608
696
  const [mediaRevision, setMediaRevision] = useState(0);
609
697
  const openImageEdit = useCallback((relativePath: string) => {
@@ -630,8 +718,10 @@ export function EditorProvider({
630
718
  if (viewPreferences.outline !== undefined) {
631
719
  setOutlineVisibleRaw(viewPreferences.outline);
632
720
  }
633
- if (viewPreferences.blockTags !== undefined) {
634
- setBlockTagsVisibleRaw(viewPreferences.blockTags);
721
+ if (viewPreferences.blockTagVisibility !== undefined) {
722
+ setBlockTagVisibilityRaw(normalizeBlockTagVisibility(viewPreferences.blockTagVisibility));
723
+ } else if (viewPreferences.blockTags !== undefined) {
724
+ setBlockTagVisibilityRaw(normalizeBlockTagVisibility(viewPreferences.blockTags));
635
725
  }
636
726
  if (viewPreferences.themeInheritance !== undefined) {
637
727
  setThemeInheritanceRaw(viewPreferences.themeInheritance);
@@ -641,7 +731,7 @@ export function EditorProvider({
641
731
  }
642
732
  }, [viewPreferences]);
643
733
 
644
- // Wrap the three setters so user-driven toggles emit a snapshot via
734
+ // Wrap the view setters so user-driven changes emit a snapshot via
645
735
  // `onViewPreferencesChange`. Refs hold the latest values + callback so
646
736
  // each wrapper can build a current snapshot without re-creating itself
647
737
  // on every state change (the setters are kept referentially stable for
@@ -654,8 +744,8 @@ export function EditorProvider({
654
744
  statusBarRef.current = statusBarVisible;
655
745
  const outlineRef = useRef(outlineVisible);
656
746
  outlineRef.current = outlineVisible;
657
- const blockTagsRef = useRef(blockTagsVisible);
658
- blockTagsRef.current = blockTagsVisible;
747
+ const blockTagVisibilityRef = useRef(blockTagVisibilityState);
748
+ blockTagVisibilityRef.current = blockTagVisibilityState;
659
749
  const themeInheritanceRef = useRef(themeInheritanceState);
660
750
  themeInheritanceRef.current = themeInheritanceState;
661
751
  const layoutModeRef = useRef(layoutModeState);
@@ -666,7 +756,8 @@ export function EditorProvider({
666
756
  inlinePreview: visible,
667
757
  showStatusBar: statusBarRef.current,
668
758
  outline: outlineRef.current,
669
- blockTags: blockTagsRef.current,
759
+ blockTags: blockTagVisibilityRef.current !== 'none',
760
+ blockTagVisibility: blockTagVisibilityRef.current,
670
761
  themeInheritance: themeInheritanceRef.current,
671
762
  layoutMode: layoutModeRef.current,
672
763
  });
@@ -677,7 +768,8 @@ export function EditorProvider({
677
768
  inlinePreview: inlinePreviewRef.current,
678
769
  showStatusBar: visible,
679
770
  outline: outlineRef.current,
680
- blockTags: blockTagsRef.current,
771
+ blockTags: blockTagVisibilityRef.current !== 'none',
772
+ blockTagVisibility: blockTagVisibilityRef.current,
681
773
  themeInheritance: themeInheritanceRef.current,
682
774
  layoutMode: layoutModeRef.current,
683
775
  });
@@ -688,29 +780,36 @@ export function EditorProvider({
688
780
  inlinePreview: inlinePreviewRef.current,
689
781
  showStatusBar: statusBarRef.current,
690
782
  outline: visible,
691
- blockTags: blockTagsRef.current,
783
+ blockTags: blockTagVisibilityRef.current !== 'none',
784
+ blockTagVisibility: blockTagVisibilityRef.current,
692
785
  themeInheritance: themeInheritanceRef.current,
693
786
  layoutMode: layoutModeRef.current,
694
787
  });
695
788
  }, []);
696
- const setBlockTagsVisible = useCallback((visible: boolean) => {
697
- setBlockTagsVisibleRaw(visible);
789
+ const setBlockTagVisibility = useCallback((visibility: BlockTagVisibility) => {
790
+ setBlockTagVisibilityRaw(visibility);
698
791
  onViewPreferencesChangeRef.current?.({
699
792
  inlinePreview: inlinePreviewRef.current,
700
793
  showStatusBar: statusBarRef.current,
701
794
  outline: outlineRef.current,
702
- blockTags: visible,
795
+ blockTags: visibility !== 'none',
796
+ blockTagVisibility: visibility,
703
797
  themeInheritance: themeInheritanceRef.current,
704
798
  layoutMode: layoutModeRef.current,
705
799
  });
706
800
  }, []);
801
+ const setBlockTagsVisible = useCallback(
802
+ (visible: boolean) => setBlockTagVisibility(visible ? 'always' : 'none'),
803
+ [setBlockTagVisibility],
804
+ );
707
805
  const setThemeInheritance = useCallback((mode: ThemeInheritance) => {
708
806
  setThemeInheritanceRaw(mode);
709
807
  onViewPreferencesChangeRef.current?.({
710
808
  inlinePreview: inlinePreviewRef.current,
711
809
  showStatusBar: statusBarRef.current,
712
810
  outline: outlineRef.current,
713
- blockTags: blockTagsRef.current,
811
+ blockTags: blockTagVisibilityRef.current !== 'none',
812
+ blockTagVisibility: blockTagVisibilityRef.current,
714
813
  themeInheritance: mode,
715
814
  layoutMode: layoutModeRef.current,
716
815
  });
@@ -721,18 +820,62 @@ export function EditorProvider({
721
820
  inlinePreview: inlinePreviewRef.current,
722
821
  showStatusBar: statusBarRef.current,
723
822
  outline: outlineRef.current,
724
- blockTags: blockTagsRef.current,
823
+ blockTags: blockTagVisibilityRef.current !== 'none',
824
+ blockTagVisibility: blockTagVisibilityRef.current,
725
825
  themeInheritance: themeInheritanceRef.current,
726
826
  layoutMode: mode,
727
827
  });
728
828
  }, []);
729
- const [tiptapEditor, setTiptapEditor] = useState<TiptapEditor | null>(null);
730
- const [monacoEditor, setMonacoEditor] = useState<MonacoEditor | null>(null);
829
+ const [tiptapEditor, setTiptapEditorRaw] = useState<TiptapEditor | null>(null);
830
+ const [monacoEditor, setMonacoEditorRaw] = useState<MonacoEditor | null>(null);
831
+ const setTiptapEditor = useCallback((editor: TiptapEditor | null) => {
832
+ tiptapEditorRef.current = editor;
833
+ setTiptapEditorRaw(editor);
834
+ }, []);
835
+ const setMonacoEditor = useCallback((editor: MonacoEditor | null) => {
836
+ monacoEditorRef.current = editor;
837
+ setMonacoEditorRaw(editor);
838
+ }, []);
839
+
840
+ useEffect(() => {
841
+ const pending = pendingEditorScrollRef.current;
842
+ if (!pending || pending.view !== activeView) return;
843
+
844
+ const restore =
845
+ activeView === 'raw' && monacoEditor
846
+ ? () => restoreMonacoScrollRatio(monacoEditor, pending.ratio)
847
+ : activeView === 'wysiwyg' && tiptapEditor
848
+ ? () => restoreWysiwygScrollRatio(tiptapEditor, pending.ratio)
849
+ : null;
850
+ if (!restore) return;
851
+
852
+ let cancelled = false;
853
+ const apply = () => {
854
+ if (cancelled) return;
855
+ restore();
856
+ if (pendingEditorScrollRef.current === pending) pendingEditorScrollRef.current = null;
857
+ };
858
+
859
+ if (typeof requestAnimationFrame === 'function') {
860
+ const frame = requestAnimationFrame(apply);
861
+ return () => {
862
+ cancelled = true;
863
+ cancelAnimationFrame(frame);
864
+ };
865
+ }
866
+
867
+ const timeout = setTimeout(apply, 0);
868
+ return () => {
869
+ cancelled = true;
870
+ clearTimeout(timeout);
871
+ };
872
+ }, [activeView, monacoEditor, tiptapEditor]);
731
873
  // Mirror the focused canvas textbox editor (published by the inline
732
874
  // SceneTextOverlay through a module channel, since the canvas renders in
733
875
  // a detached React root outside this provider) so the toolbar can target it.
734
876
  const [activeSceneText, setActiveSceneText] = useState<SceneTextHandle | null>(null);
735
- useEffect(() => sceneTextChannel.subscribe(setActiveSceneText), []);
877
+ const sceneTextChannel = useMemo(createSceneTextChannel, []);
878
+ useEffect(() => sceneTextChannel.subscribe(setActiveSceneText), [sceneTextChannel]);
736
879
 
737
880
  const articleIdRef = useRef(articleId);
738
881
  articleIdRef.current = articleId;
@@ -744,6 +887,7 @@ export function EditorProvider({
744
887
 
745
888
  // Debounced parse on markdown source change
746
889
  const parseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
890
+ const skipInitialDebouncedParseRef = useRef(Boolean(initialMarkdown));
747
891
 
748
892
  const doParse = useCallback((source: string) => {
749
893
  setIsParsing(true);
@@ -778,9 +922,14 @@ export function EditorProvider({
778
922
  // parser on TypeScript / JSON / a binary image asset.
779
923
  useEffect(() => {
780
924
  if (editorMode !== 'markdown') return;
925
+ if (skipInitialDebouncedParseRef.current) {
926
+ skipInitialDebouncedParseRef.current = false;
927
+ return;
928
+ }
781
929
  if (parseTimeoutRef.current) {
782
930
  clearTimeout(parseTimeoutRef.current);
783
931
  }
932
+ setIsParsing(true);
784
933
  parseTimeoutRef.current = setTimeout(() => {
785
934
  doParse(markdownSource);
786
935
  }, 150);
@@ -983,6 +1132,7 @@ export function EditorProvider({
983
1132
  inlinePreviewVisible,
984
1133
  statusBarVisible,
985
1134
  outlineVisible,
1135
+ blockTagVisibility: blockTagVisibilityState,
986
1136
  blockTagsVisible,
987
1137
  themeInheritance: themeInheritanceState,
988
1138
  layoutMode: layoutModeState,
@@ -993,9 +1143,11 @@ export function EditorProvider({
993
1143
  imageEditTarget,
994
1144
  mediaRevision,
995
1145
  allowRecording,
1146
+ allowNarrate,
996
1147
  tiptapEditor,
997
1148
  monacoEditor,
998
1149
  activeSceneText,
1150
+ sceneTextChannel,
999
1151
  workspaceContainer,
1000
1152
  versioning,
1001
1153
  saveVersion,
@@ -1019,6 +1171,7 @@ export function EditorProvider({
1019
1171
  setInlinePreviewVisible,
1020
1172
  setStatusBarVisible,
1021
1173
  setOutlineVisible,
1174
+ setBlockTagVisibility,
1022
1175
  setBlockTagsVisible,
1023
1176
  setThemeInheritance,
1024
1177
  insertAtCursor,
@@ -1040,6 +1193,7 @@ export function EditorProvider({
1040
1193
  inlinePreviewVisible,
1041
1194
  statusBarVisible,
1042
1195
  outlineVisible,
1196
+ blockTagVisibilityState,
1043
1197
  blockTagsVisible,
1044
1198
  themeInheritanceState,
1045
1199
  layoutModeState,
@@ -1050,6 +1204,7 @@ export function EditorProvider({
1050
1204
  tiptapEditor,
1051
1205
  monacoEditor,
1052
1206
  activeSceneText,
1207
+ sceneTextChannel,
1053
1208
  workspaceContainer,
1054
1209
  versioning,
1055
1210
  saveVersion,
@@ -1073,6 +1228,7 @@ export function EditorProvider({
1073
1228
  setInlinePreviewVisible,
1074
1229
  setStatusBarVisible,
1075
1230
  setOutlineVisible,
1231
+ setBlockTagVisibility,
1076
1232
  setBlockTagsVisible,
1077
1233
  setThemeInheritance,
1078
1234
  insertAtCursor,
@@ -1080,6 +1236,7 @@ export function EditorProvider({
1080
1236
  imageEditTarget,
1081
1237
  mediaRevision,
1082
1238
  allowRecording,
1239
+ allowNarrate,
1083
1240
  openImageEdit,
1084
1241
  closeImageEdit,
1085
1242
  bumpMediaRevision,