@bendyline/squisq-editor-react 2.0.0 → 2.1.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 (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. package/src/writeCanvasSettings.ts +30 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-editor-react",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "React editor shell with raw/WYSIWYG/preview modes for Squisq documents",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -56,9 +56,9 @@
56
56
  }
57
57
  },
58
58
  "dependencies": {
59
- "@bendyline/squisq": "2.0.0",
60
- "@bendyline/squisq-formats": "2.0.0",
61
- "@bendyline/squisq-react": "2.0.0",
59
+ "@bendyline/squisq": "2.1.0",
60
+ "@bendyline/squisq-formats": "2.1.0",
61
+ "@bendyline/squisq-react": "2.1.0",
62
62
  "@fortawesome/fontawesome-free": "7.2.0",
63
63
  "@monaco-editor/react": "4.7.0",
64
64
  "@tiptap/extension-image": "2.27.2",
@@ -74,7 +74,8 @@
74
74
  "@tiptap/pm": "2.27.2",
75
75
  "@tiptap/react": "2.27.2",
76
76
  "@tiptap/starter-kit": "2.27.2",
77
- "@tiptap/suggestion": "2.27.2"
77
+ "@tiptap/suggestion": "2.27.2",
78
+ "mermaid": "11.16.0"
78
79
  },
79
80
  "devDependencies": {
80
81
  "@types/react": "18.3.28",
@@ -25,17 +25,14 @@ import {
25
25
  import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
26
26
  import { ThemePicker } from './ThemePicker';
27
27
  import { resolvePersistedTransformStyleId } from './transformStyleId';
28
+ import {
29
+ FRONTMATTER_SETTING_DEFAULTS,
30
+ FRONTMATTER_SETTING_KEYS,
31
+ omitFrontmatterDefault,
32
+ } from './frontmatterSettings';
28
33
 
29
34
  // ── Frontmatter key constants ─────────────────────────────────────
30
35
 
31
- /** Mirror of `FM_KEYS` in PreviewControls.tsx. Canonical names are the
32
- * ones we write; legacy aliases are still read so older docs work. */
33
- const FM = {
34
- theme: { canonical: 'squisq-theme', legacy: ['themeId', 'theme'] },
35
- transform: { canonical: 'squisq-transform', legacy: 'transform-style' },
36
- captions: { canonical: 'squisq-captions', legacy: 'caption-style' },
37
- } as const;
38
-
39
36
  function readFm(
40
37
  fm: Record<string, unknown> | undefined,
41
38
  canonical: string,
@@ -80,9 +77,17 @@ export function DocumentSettingsDialog({
80
77
  const currentTheme = readFrontmatterThemeId(frontmatter) ?? '';
81
78
  const currentTransform =
82
79
  resolvePersistedTransformStyleId(
83
- readFm(frontmatter, FM.transform.canonical, FM.transform.legacy),
80
+ readFm(
81
+ frontmatter,
82
+ FRONTMATTER_SETTING_KEYS.transform.canonical,
83
+ FRONTMATTER_SETTING_KEYS.transform.legacy,
84
+ ),
84
85
  ) ?? '';
85
- const currentCaptions = readFm(frontmatter, FM.captions.canonical, FM.captions.legacy);
86
+ const currentCaptions = readFm(
87
+ frontmatter,
88
+ FRONTMATTER_SETTING_KEYS.captions.canonical,
89
+ FRONTMATTER_SETTING_KEYS.captions.legacy,
90
+ );
86
91
 
87
92
  const [title, setTitle] = useState(currentTitle);
88
93
  const [theme, setTheme] = useState(currentTheme);
@@ -132,20 +137,29 @@ export function DocumentSettingsDialog({
132
137
 
133
138
  const updates: Record<string, string | null> = {
134
139
  title: nextTitle,
135
- [FM.theme.canonical]: theme || null,
140
+ [FRONTMATTER_SETTING_KEYS.theme.canonical]: omitFrontmatterDefault(
141
+ theme || FRONTMATTER_SETTING_DEFAULTS.theme,
142
+ FRONTMATTER_SETTING_DEFAULTS.theme,
143
+ ),
136
144
  // Saving settings canonicalizes all older theme spellings.
137
- [FM.theme.legacy[0]]: null,
138
- [FM.theme.legacy[1]]: null,
139
- [FM.transform.canonical]: transform || null,
140
- [FM.transform.legacy]: null,
141
- [FM.captions.canonical]: captions || null,
142
- ...(currentCaptions && captions !== currentCaptions ? { [FM.captions.legacy]: null } : {}),
145
+ [FRONTMATTER_SETTING_KEYS.theme.legacy[0]]: null,
146
+ [FRONTMATTER_SETTING_KEYS.theme.legacy[1]]: null,
147
+ [FRONTMATTER_SETTING_KEYS.transform.canonical]: omitFrontmatterDefault(
148
+ transform,
149
+ FRONTMATTER_SETTING_DEFAULTS.transform,
150
+ ),
151
+ [FRONTMATTER_SETTING_KEYS.transform.legacy]: null,
152
+ [FRONTMATTER_SETTING_KEYS.captions.canonical]: omitFrontmatterDefault(
153
+ captions || FRONTMATTER_SETTING_DEFAULTS.captions,
154
+ FRONTMATTER_SETTING_DEFAULTS.captions,
155
+ ),
156
+ [FRONTMATTER_SETTING_KEYS.captions.legacy]: null,
143
157
  };
144
158
 
145
159
  const nextSource = setFrontmatterValues(markdownSource, updates);
146
160
  onSave(nextSource);
147
161
  },
148
- [title, theme, transform, captions, inferredTitle, markdownSource, currentCaptions, onSave],
162
+ [title, theme, transform, captions, inferredTitle, markdownSource, onSave],
149
163
  );
150
164
 
151
165
  return (
@@ -153,6 +153,8 @@ export interface EditorState {
153
153
  colorScheme: EditorColorScheme;
154
154
  /** Operating mode — 'markdown' for the full shell, 'code' for Monaco-only. */
155
155
  editorMode: EditorMode;
156
+ /** Whether the host-triggered Find toolbar is active. */
157
+ findMode: boolean;
156
158
  /** Monaco language ID for the Raw editor. */
157
159
  language: string;
158
160
  /**
@@ -263,6 +265,8 @@ export interface EditorActions {
263
265
  setMarkdownDoc: (doc: MarkdownDocument) => void;
264
266
  /** Switch the active view */
265
267
  setActiveView: (view: EditorView) => void;
268
+ /** Enter or leave the host-triggered Find toolbar mode. */
269
+ setFindMode: (active: boolean) => void;
266
270
  /** Register / unregister the Tiptap editor instance (called by WysiwygEditor) */
267
271
  setTiptapEditor: (editor: TiptapEditor | null) => void;
268
272
  /** Register / unregister the Monaco editor instance (called by RawEditor) */
@@ -462,6 +466,13 @@ export interface EditorProviderProps {
462
466
  fileName?: string;
463
467
  /** Explicit Monaco language ID — wins over the fileName-derived one. */
464
468
  language?: string;
469
+ /**
470
+ * Controlled Find-mode state. No trigger button is rendered by default;
471
+ * hosts can set this prop or call `setFindMode` from editor context.
472
+ */
473
+ findMode?: boolean;
474
+ /** Notified when Find mode requests a state change (for example, its X button). */
475
+ onFindModeChange?: (active: boolean) => void;
465
476
  /**
466
477
  * Initial visibility of the inline preview gutter. Defaults to false.
467
478
  * The toolbar's View menu can toggle it at runtime.
@@ -577,6 +588,8 @@ export function EditorProvider({
577
588
  allowNarrate = true,
578
589
  fileName,
579
590
  language,
591
+ findMode: controlledFindMode,
592
+ onFindModeChange,
580
593
  inlinePreview = false,
581
594
  showStatusBar = true,
582
595
  outline = false,
@@ -618,6 +631,16 @@ export function EditorProvider({
618
631
  const [activeView, setActiveViewRaw] = useState<EditorView>(
619
632
  editorMode === 'markdown' ? initialView : 'raw',
620
633
  );
634
+ const [uncontrolledFindMode, setUncontrolledFindMode] = useState(false);
635
+ const findMode = editorMode !== 'image' && (controlledFindMode ?? uncontrolledFindMode);
636
+ const setFindMode = useCallback(
637
+ (active: boolean) => {
638
+ if (editorMode === 'image') return;
639
+ if (controlledFindMode === undefined) setUncontrolledFindMode(active);
640
+ onFindModeChange?.(active);
641
+ },
642
+ [controlledFindMode, editorMode, onFindModeChange],
643
+ );
621
644
  const activeViewRef = useRef(activeView);
622
645
  activeViewRef.current = activeView;
623
646
  const tiptapEditorRef = useRef<TiptapEditor | null>(null);
@@ -1128,6 +1151,7 @@ export function EditorProvider({
1128
1151
  isParsing,
1129
1152
  colorScheme,
1130
1153
  editorMode,
1154
+ findMode,
1131
1155
  language: resolvedLanguage,
1132
1156
  inlinePreviewVisible,
1133
1157
  statusBarVisible,
@@ -1165,6 +1189,7 @@ export function EditorProvider({
1165
1189
  addBlock,
1166
1190
  setMarkdownDoc,
1167
1191
  setActiveView,
1192
+ setFindMode,
1168
1193
  setTiptapEditor,
1169
1194
  setMonacoEditor,
1170
1195
  setColorScheme,
@@ -1189,6 +1214,7 @@ export function EditorProvider({
1189
1214
  isParsing,
1190
1215
  colorScheme,
1191
1216
  editorMode,
1217
+ findMode,
1192
1218
  resolvedLanguage,
1193
1219
  inlinePreviewVisible,
1194
1220
  statusBarVisible,
@@ -1222,6 +1248,7 @@ export function EditorProvider({
1222
1248
  addBlock,
1223
1249
  setMarkdownDoc,
1224
1250
  setActiveView,
1251
+ setFindMode,
1225
1252
  setTiptapEditor,
1226
1253
  setMonacoEditor,
1227
1254
  setColorScheme,
@@ -65,6 +65,7 @@ import {
65
65
  import type { PrunePolicy, SaveVersionResult } from '@bendyline/squisq/versions';
66
66
  import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
67
67
  import { MediaContext } from '@bendyline/squisq-react';
68
+ import { writeCanvasSettingsStyle, type WriteCanvasSettings } from './writeCanvasSettings';
68
69
 
69
70
  export type { EditorColorScheme } from './EditorContext';
70
71
 
@@ -210,6 +211,12 @@ export interface EditorShellProps {
210
211
  * toolbar, tabs, and status bar keep their normal sizing.
211
212
  */
212
213
  thinMargins?: boolean;
214
+ /**
215
+ * Host-controlled typography for the markdown Write canvas. `textSize` is
216
+ * measured in CSS pixels and `lineSpacing` is a unitless line-height
217
+ * multiplier. This is presentation-only and does not modify the markdown.
218
+ */
219
+ writeCanvasSettings?: WriteCanvasSettings;
213
220
  /**
214
221
  * Render the bottom status bar (word / character / line / block counts
215
222
  * and parse-state indicator). Defaults to `true`. Set to `false` in
@@ -243,6 +250,14 @@ export interface EditorShellProps {
243
250
  * switches the shell into code mode.
244
251
  */
245
252
  language?: string;
253
+ /**
254
+ * Controlled Find-mode state. Find has no built-in trigger button; hosts
255
+ * opt in by setting this prop (or by calling `setFindMode` from a toolbar
256
+ * slot rendered inside the editor context).
257
+ */
258
+ findMode?: boolean;
259
+ /** Called when Find mode requests a state change, including its X button. */
260
+ onFindModeChange?: (active: boolean) => void;
246
261
  /**
247
262
  * Optional async provider for `@`-mention suggestions. When supplied,
248
263
  * typing `@` inside the editor opens a popover of candidates; selecting
@@ -431,10 +446,13 @@ export function EditorShell({
431
446
  fullWidth = false,
432
447
  uxFont,
433
448
  thinMargins = false,
449
+ writeCanvasSettings,
434
450
  showStatusBar = true,
435
451
  imageDisplayMode = 'inline',
436
452
  fileName,
437
453
  language,
454
+ findMode,
455
+ onFindModeChange,
438
456
  mentionProvider,
439
457
  documentLinkProvider,
440
458
  allowRecording = true,
@@ -498,6 +516,8 @@ export function EditorShell({
498
516
  allowNarrate={allowNarrate}
499
517
  fileName={fileName}
500
518
  language={language}
519
+ findMode={findMode}
520
+ onFindModeChange={onFindModeChange}
501
521
  inlinePreview={inlinePreview}
502
522
  showStatusBar={showStatusBar}
503
523
  outline={outline}
@@ -527,6 +547,7 @@ export function EditorShell({
527
547
  fullWidth={fullWidth}
528
548
  uxFont={uxFont}
529
549
  thinMargins={thinMargins}
550
+ writeCanvasSettings={writeCanvasSettings}
530
551
  readOnly={readOnly}
531
552
  imageSrc={imageSrc}
532
553
  imageAlt={imageAlt}
@@ -564,6 +585,7 @@ interface EditorShellInnerProps {
564
585
  fullWidth: boolean;
565
586
  uxFont?: string;
566
587
  thinMargins: boolean;
588
+ writeCanvasSettings?: WriteCanvasSettings;
567
589
  readOnly: boolean;
568
590
  imageSrc?: string;
569
591
  imageAlt?: string;
@@ -597,6 +619,7 @@ function EditorShellInner({
597
619
  fullWidth,
598
620
  uxFont,
599
621
  thinMargins,
622
+ writeCanvasSettings,
600
623
  readOnly,
601
624
  imageSrc,
602
625
  imageAlt,
@@ -716,7 +739,7 @@ function EditorShellInner({
716
739
  *
717
740
  * Without this, upload-via-MediaBin and upload-via-drop both
718
741
  * added the file to the bin and nowhere else — the composer sent
719
- * an empty body and the downstream gezel reported "nothing came
742
+ * an empty body and the downstream consumer reported "nothing came
720
743
  * through."
721
744
  */
722
745
  const insertMediaRef = useCallback(
@@ -862,6 +885,7 @@ function EditorShellInner({
862
885
  // tabs, status bar) consume `--squisq-ux-font` as their
863
886
  // `font-family`, falling back to the system stack when unset.
864
887
  ...(uxFont ? ({ '--squisq-ux-font': uxFont } as CSSProperties) : {}),
888
+ ...writeCanvasSettingsStyle(writeCanvasSettings),
865
889
  // Exposed so the toolbar's view-tabs section AND the outline pane read
866
890
  // one shared width, lining up the view-tabs' right-edge separator with
867
891
  // the outline's right edge. A fixed `outlineWidth` pins it to px;
@@ -47,6 +47,11 @@ import {
47
47
  } from './customThemes';
48
48
  import { Icon } from './Icon';
49
49
  import { resolvePersistedTransformStyleId } from './transformStyleId';
50
+ import {
51
+ FRONTMATTER_SETTING_DEFAULTS,
52
+ FRONTMATTER_SETTING_KEYS,
53
+ omitFrontmatterDefault,
54
+ } from './frontmatterSettings';
50
55
 
51
56
  // ── Context ──────────────────────────────────────────────────────
52
57
 
@@ -198,16 +203,6 @@ export interface PreviewSettingsProviderProps {
198
203
  themeOverride?: Theme | null;
199
204
  }
200
205
 
201
- /** Frontmatter keys we read/write for preview settings. The squisq-prefixed
202
- * keys are the canonical names; the legacy keys are still read so existing
203
- * documents keep working. Persistence (writes) uses only the squisq names. */
204
- const FM_KEYS = {
205
- theme: { canonical: 'squisq-theme', legacy: ['themeId', 'theme'] as const },
206
- transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
207
- captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
208
- coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
209
- } as const;
210
-
211
206
  function readFrontmatterKey(
212
207
  fm: Record<string, unknown> | undefined,
213
208
  canonical: string,
@@ -226,7 +221,7 @@ export function PreviewSettingsProvider({
226
221
  const { markdownSource, setMarkdownSource, allowNarrate } = useEditorContext();
227
222
 
228
223
  const persistFrontmatter = useCallback(
229
- (updates: Record<string, string | null>) => {
224
+ (updates: Record<string, string | number | boolean | null | undefined>) => {
230
225
  const next = setFrontmatterValues(markdownSource, updates);
231
226
  if (next !== markdownSource) {
232
227
  setMarkdownSource(next);
@@ -271,7 +266,7 @@ export function PreviewSettingsProvider({
271
266
  );
272
267
  const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
273
268
  useEffect(() => setSelectedThemeId(null), [fmTheme]);
274
- const resolvedThemeId = selectedThemeId ?? fmTheme ?? 'standard';
269
+ const resolvedThemeId = selectedThemeId ?? fmTheme ?? FRONTMATTER_SETTING_DEFAULTS.theme;
275
270
  // Doc themes precede browser-library themes in `allThemes`; choosing a
276
271
  // library-only entry copies it into the document below for portable export.
277
272
  const resolvedTheme = useMemo(
@@ -300,9 +295,12 @@ export function PreviewSettingsProvider({
300
295
  const selectedCustom = customThemes.find((theme) => theme.id === id);
301
296
  const alreadyDocScoped = docThemes.some((theme) => theme.id === id);
302
297
  const updates: Record<string, string | null> = {
303
- [FM_KEYS.theme.canonical]: id,
304
- [FM_KEYS.theme.legacy[0]]: null,
305
- [FM_KEYS.theme.legacy[1]]: null,
298
+ [FRONTMATTER_SETTING_KEYS.theme.canonical]: omitFrontmatterDefault(
299
+ id,
300
+ FRONTMATTER_SETTING_DEFAULTS.theme,
301
+ ),
302
+ [FRONTMATTER_SETTING_KEYS.theme.legacy[0]]: null,
303
+ [FRONTMATTER_SETTING_KEYS.theme.legacy[1]]: null,
306
304
  };
307
305
  if (selectedCustom && !alreadyDocScoped) {
308
306
  updates[FRONTMATTER_CUSTOM_THEMES_KEY] =
@@ -337,7 +335,7 @@ export function PreviewSettingsProvider({
337
335
  idx >= 0 ? docThemes.map((t, i) => (i === idx ? theme : t)) : [...docThemes, theme];
338
336
  const updates: Record<string, string | null> = {
339
337
  [FRONTMATTER_CUSTOM_THEMES_KEY]: writeCustomThemesToFrontmatter(nextThemes) ?? null,
340
- [FM_KEYS.theme.canonical]: theme.id,
338
+ [FRONTMATTER_SETTING_KEYS.theme.canonical]: theme.id,
341
339
  };
342
340
  if (extras?.templates && extras.templates.length > 0) {
343
341
  const existing = doc?.customTemplates ?? [];
@@ -367,7 +365,11 @@ export function PreviewSettingsProvider({
367
365
  const fmTransform = useMemo(
368
366
  () =>
369
367
  resolvePersistedTransformStyleId(
370
- readFrontmatterKey(frontmatter, FM_KEYS.transform.canonical, FM_KEYS.transform.legacy),
368
+ readFrontmatterKey(
369
+ frontmatter,
370
+ FRONTMATTER_SETTING_KEYS.transform.canonical,
371
+ FRONTMATTER_SETTING_KEYS.transform.legacy,
372
+ ),
371
373
  ),
372
374
  [frontmatter],
373
375
  );
@@ -379,7 +381,13 @@ export function PreviewSettingsProvider({
379
381
  setSelectedTransformStyle(id);
380
382
  if (id !== null) {
381
383
  // Empty string = "None" — remove the key rather than writing a blank value.
382
- persistFrontmatter({ [FM_KEYS.transform.canonical]: id === '' ? null : id });
384
+ persistFrontmatter({
385
+ [FRONTMATTER_SETTING_KEYS.transform.canonical]: omitFrontmatterDefault(
386
+ id,
387
+ FRONTMATTER_SETTING_DEFAULTS.transform,
388
+ ),
389
+ [FRONTMATTER_SETTING_KEYS.transform.legacy]: null,
390
+ });
383
391
  }
384
392
  },
385
393
  [persistFrontmatter],
@@ -391,19 +399,30 @@ export function PreviewSettingsProvider({
391
399
  const fmCaptionMode = useMemo(
392
400
  () =>
393
401
  resolveFrontmatterCaptionMode(
394
- readFrontmatterKey(frontmatter, FM_KEYS.captions.canonical, FM_KEYS.captions.legacy),
402
+ readFrontmatterKey(
403
+ frontmatter,
404
+ FRONTMATTER_SETTING_KEYS.captions.canonical,
405
+ FRONTMATTER_SETTING_KEYS.captions.legacy,
406
+ ),
395
407
  ),
396
408
  [frontmatter],
397
409
  );
398
410
  const [selectedCaptionMode, setSelectedCaptionMode] = useState<CaptionMode | null>(null);
399
411
  useEffect(() => setSelectedCaptionMode(null), [fmCaptionMode]);
400
- const activeCaptionMode = selectedCaptionMode ?? fmCaptionMode ?? 'standard';
412
+ const activeCaptionMode =
413
+ selectedCaptionMode ?? fmCaptionMode ?? FRONTMATTER_SETTING_DEFAULTS.captions;
401
414
  const activeCaptionsEnabled = activeCaptionMode !== 'off';
402
415
  const activeCaptionStyle: CaptionStyle = activeCaptionMode === 'social' ? 'social' : 'standard';
403
416
  const handleSetCaptionMode = useCallback(
404
417
  (mode: CaptionMode) => {
405
418
  setSelectedCaptionMode(mode);
406
- persistFrontmatter({ [FM_KEYS.captions.canonical]: mode });
419
+ persistFrontmatter({
420
+ [FRONTMATTER_SETTING_KEYS.captions.canonical]: omitFrontmatterDefault(
421
+ mode,
422
+ FRONTMATTER_SETTING_DEFAULTS.captions,
423
+ ),
424
+ [FRONTMATTER_SETTING_KEYS.captions.legacy]: null,
425
+ });
407
426
  },
408
427
  [persistFrontmatter],
409
428
  );
@@ -413,17 +432,31 @@ export function PreviewSettingsProvider({
413
432
  const fmCoverSlide = useMemo(
414
433
  () =>
415
434
  resolveFrontmatterBoolean(
416
- readFrontmatterKey(frontmatter, FM_KEYS.coverSlide.canonical, FM_KEYS.coverSlide.legacy),
435
+ readFrontmatterKey(
436
+ frontmatter,
437
+ FRONTMATTER_SETTING_KEYS.coverSlide.canonical,
438
+ FRONTMATTER_SETTING_KEYS.coverSlide.legacy,
439
+ ),
417
440
  ),
418
441
  [frontmatter],
419
442
  );
420
443
  const [selectedCoverSlide, setSelectedCoverSlide] = useState<boolean | null>(null);
421
444
  useEffect(() => setSelectedCoverSlide(null), [fmCoverSlide]);
422
- const activeCoverSlide = selectedCoverSlide ?? fmCoverSlide ?? true;
445
+ const activeCoverSlide =
446
+ selectedCoverSlide ?? fmCoverSlide ?? FRONTMATTER_SETTING_DEFAULTS.coverSlide;
423
447
  const handleSetCoverSlideEnabled = useCallback(
424
448
  (enabled: boolean) => {
425
449
  setSelectedCoverSlide(enabled);
426
- persistFrontmatter({ [FM_KEYS.coverSlide.canonical]: enabled ? 'true' : 'false' });
450
+ persistFrontmatter({
451
+ // The cover is enabled by default, so only persist the non-default
452
+ // state. Pass the boolean through so YAML writes `false`, not
453
+ // the string `"false"`.
454
+ [FRONTMATTER_SETTING_KEYS.coverSlide.canonical]: omitFrontmatterDefault(
455
+ enabled,
456
+ FRONTMATTER_SETTING_DEFAULTS.coverSlide,
457
+ ),
458
+ [FRONTMATTER_SETTING_KEYS.coverSlide.legacy]: null,
459
+ });
427
460
  },
428
461
  [persistFrontmatter],
429
462
  );
@@ -583,6 +616,20 @@ const SUMMARIZE_TOOLTIP =
583
616
  type ControlKey = 'format' | 'theme' | 'transform' | 'captions' | 'cover';
584
617
  const CONTROL_KEYS: ControlKey[] = ['format', 'theme', 'transform', 'captions', 'cover'];
585
618
 
619
+ /**
620
+ * Controls that apply to the active display mode. Page (`linear`) is a
621
+ * variable-height HTML rendition: the aspect-ratio format switch only
622
+ * affects embedded canvas sections (which default sensibly) and captions
623
+ * never applied, so both are hidden there. Cover, Theme, and Summarize
624
+ * remain live in every mode.
625
+ */
626
+ function controlKeysForMode(displayMode: string): ControlKey[] {
627
+ if (displayMode === 'linear') {
628
+ return CONTROL_KEYS.filter((key) => key !== 'format' && key !== 'captions');
629
+ }
630
+ return CONTROL_KEYS;
631
+ }
632
+
586
633
  const PREVIEW_POPOVER_GAP = 4;
587
634
  const PREVIEW_POPOVER_MARGIN = 8;
588
635
  const PREVIEW_POPOVER_FALLBACK_WIDTH = 220;
@@ -633,6 +680,7 @@ const selectStyle: React.CSSProperties = {
633
680
  */
634
681
  export function PreviewToolbarControls() {
635
682
  const s = usePreviewSettings();
683
+ const controlKeys = controlKeysForMode(s.activeDisplayMode);
636
684
  const [visibleCount, setVisibleCount] = useState(CONTROL_KEYS.length);
637
685
  const [popoverOpen, setPopoverOpen] = useState(false);
638
686
  // `rootRef` (flex:1) always spans the toolbar's leftover width, so its
@@ -699,7 +747,7 @@ export function PreviewToolbarControls() {
699
747
  ro.observe(probe);
700
748
  measure();
701
749
  return () => ro.disconnect();
702
- }, []);
750
+ }, [controlKeys.length]);
703
751
 
704
752
  // Close popover on outside click
705
753
  useEffect(() => {
@@ -867,9 +915,9 @@ export function PreviewToolbarControls() {
867
915
  }
868
916
  };
869
917
 
870
- const hasOverflow = visibleCount < CONTROL_KEYS.length;
871
- const visibleKeys = CONTROL_KEYS.slice(0, visibleCount);
872
- const overflowKeys = CONTROL_KEYS.slice(visibleCount);
918
+ const hasOverflow = visibleCount < controlKeys.length;
919
+ const visibleKeys = controlKeys.slice(0, visibleCount);
920
+ const overflowKeys = controlKeys.slice(visibleCount);
873
921
 
874
922
  useEffect(() => {
875
923
  if (!hasOverflow && popoverOpen) closePopover();
@@ -887,7 +935,7 @@ export function PreviewToolbarControls() {
887
935
  inline/overflow split. Absolutely positioned so it never affects
888
936
  layout. */}
889
937
  <div className="squisq-preview-controls-probe" ref={probeRef} aria-hidden="true">
890
- {CONTROL_KEYS.map((key) => renderControl(key, false))}
938
+ {controlKeys.map((key) => renderControl(key, false))}
891
939
  </div>
892
940
 
893
941
  {visibleKeys.length > 0 && (
@@ -14,7 +14,7 @@ import { createPortal } from 'react-dom';
14
14
  import { DocPlayer, LinearDocView, useMediaProvider } from '@bendyline/squisq-react';
15
15
  import type { AudioController, PlaybackState } from '@bendyline/squisq-react';
16
16
  import type { Doc } from '@bendyline/squisq/schemas';
17
- import { applyTransform } from '@bendyline/squisq/transform';
17
+ import { applyTransform, resolveTransformStyle } from '@bendyline/squisq/transform';
18
18
  import { resolveAudioMapping } from '@bendyline/squisq/doc';
19
19
  import type { ContentContainer } from '@bendyline/squisq/storage';
20
20
  import { useEditorContext } from './EditorContext';
@@ -333,6 +333,10 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
333
333
  viewport={activeViewport}
334
334
  theme={activeTheme}
335
335
  globalKeyboardShortcuts={!audience}
336
+ showCover={activeCoverSlide}
337
+ transformPage={
338
+ activeTransformStyle ? resolveTransformStyle(activeTransformStyle).page : undefined
339
+ }
336
340
  />
337
341
  );
338
342
  }
package/src/RawEditor.tsx CHANGED
@@ -16,6 +16,7 @@ import { BLOCK_META_KEY_DESCRIPTORS, tokenizeAttrTokens } from '@bendyline/squis
16
16
  import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
17
17
  import { canHandleSquisqMediaDrop, ownsMonacoModel } from './rawEditorIsolation';
18
18
  import { useMonacoLoader } from './useMonacoLoader';
19
+ import { isMarkdownFencedCodeLine, markdownFencedCodeLineMask } from './markdownCodeFence';
19
20
 
20
21
  // Monaco is loaded lazily through `useMonacoLoader` (see the hook for the
21
22
  // rationale). The type-only `import type * as monaco from 'monaco-editor'`
@@ -200,6 +201,9 @@ export function RawEditor({
200
201
  triggerCharacters: ['['],
201
202
  provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
202
203
  if (!ownsMonacoModel(editor, model)) return { suggestions: [] };
204
+ if (isMarkdownFencedCodeLine(model.getValue(), position.lineNumber)) {
205
+ return { suggestions: [] };
206
+ }
203
207
  const lineContent = model.getLineContent(position.lineNumber);
204
208
 
205
209
  // Only trigger inside a heading line that has {[ before the cursor
@@ -314,6 +318,9 @@ export function RawEditor({
314
318
  triggerCharacters: ['['],
315
319
  provideCompletionItems(model, position) {
316
320
  if (!ownsMonacoModel(editor, model)) return { suggestions: [] };
321
+ if (isMarkdownFencedCodeLine(model.getValue(), position.lineNumber)) {
322
+ return { suggestions: [] };
323
+ }
317
324
  const lineContent = model.getLineContent(position.lineNumber);
318
325
  const textBeforeCursor = lineContent.substring(0, position.column - 1);
319
326
  const textAfterCursor = lineContent.substring(position.column - 1);
@@ -397,6 +404,9 @@ export function RawEditor({
397
404
  triggerCharacters: ['=', ' '],
398
405
  provideCompletionItems(model, position) {
399
406
  if (!ownsMonacoModel(editor, model)) return { suggestions: [] };
407
+ if (isMarkdownFencedCodeLine(model.getValue(), position.lineNumber)) {
408
+ return { suggestions: [] };
409
+ }
400
410
  const lineContent = model.getLineContent(position.lineNumber);
401
411
  // Block-meta attributes only mean something on a heading's
402
412
  // template annotation — same gate as the template provider.
@@ -624,8 +634,10 @@ export function RawEditor({
624
634
 
625
635
  const decorations: monaco.editor.IModelDeltaDecoration[] = [];
626
636
  const lines = model.getLineCount();
637
+ const fencedLines = markdownFencedCodeLineMask(model.getValue());
627
638
  const re = /\{\[([a-zA-Z0-9_:-]+)\]\}/g;
628
639
  for (let line = 1; line <= lines; line++) {
640
+ if (fencedLines[line - 1]) continue;
629
641
  const text = model.getLineContent(line);
630
642
  re.lastIndex = 0;
631
643
  let match: RegExpExecArray | null;
@@ -26,6 +26,7 @@ import { RecorderPanel } from './recorder/RecorderPanel.js';
26
26
  import type { RecorderSaveResult } from './recorder/RecorderModal.js';
27
27
  import { insertMediaBlock } from './recorder/insertMediaBlock.js';
28
28
  import { useEditorContext } from './EditorContext';
29
+ import { markdownFencedCodeLineMask } from './markdownCodeFence';
29
30
 
30
31
  /**
31
32
  * Insert a narration annotation `{[audio=filename]}` onto the heading
@@ -42,8 +43,10 @@ function annotateMonacoHeading(
42
43
  if (!model) return false;
43
44
  const position = editor.getPosition();
44
45
  if (!position) return false;
46
+ const fencedLines = markdownFencedCodeLineMask(model.getValue());
45
47
  // Walk upward from the cursor to find the nearest heading line.
46
48
  for (let line = position.lineNumber; line >= 1; line--) {
49
+ if (fencedLines[line - 1]) continue;
47
50
  const text = model.getLineContent(line);
48
51
  if (!/^#{1,6}\s/.test(text)) continue;
49
52
  // Skip if the heading already has an audio annotation — don't
@@ -81,6 +84,7 @@ export function RecorderEntry() {
81
84
  bumpMediaRevision,
82
85
  markdownSource,
83
86
  setMarkdownSource,
87
+ colorScheme,
84
88
  } = useEditorContext();
85
89
 
86
90
  const handleSave = useCallback(
@@ -150,6 +154,7 @@ export function RecorderEntry() {
150
154
  <RecorderPanel
151
155
  mediaProvider={mediaProvider}
152
156
  container={workspaceContainer}
157
+ colorScheme={colorScheme}
153
158
  onSave={handleSave}
154
159
  className="squisq-toolbar-button"
155
160
  />