@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
@@ -30,11 +30,27 @@ import {
30
30
  export interface TransitionPickerProps {
31
31
  value: TransitionFields;
32
32
  onChange: (next: TransitionFields) => void;
33
+ /** Surface scheme copied onto the portaled flyout so it can style independently of the shell. */
34
+ colorScheme?: 'light' | 'dark';
35
+ /** Active document-theme accent used for open and selected states. */
36
+ accentColor?: string;
33
37
  }
34
38
 
35
- const FLYOUT_ID = 'squisq-transition-flyout-portal';
39
+ /**
40
+ * DOM id of the portaled transition flyout. Exported so hosts that embed the
41
+ * picker inside their own popovers (e.g. the toolbar overflow menu) can treat
42
+ * clicks inside the flyout as "inside" in their outside-click handling.
43
+ */
44
+ export const TRANSITION_FLYOUT_PORTAL_ID = 'squisq-transition-flyout-portal';
45
+
46
+ const FLYOUT_ID = TRANSITION_FLYOUT_PORTAL_ID;
36
47
 
37
- export function TransitionPicker({ value, onChange }: TransitionPickerProps) {
48
+ export function TransitionPicker({
49
+ value,
50
+ onChange,
51
+ colorScheme = 'light',
52
+ accentColor,
53
+ }: TransitionPickerProps) {
38
54
  const [open, setOpen] = useState(false);
39
55
  const [style, setStyle] = useState<React.CSSProperties>({});
40
56
  const triggerRef = useRef<HTMLButtonElement>(null);
@@ -112,13 +128,22 @@ export function TransitionPicker({ value, onChange }: TransitionPickerProps) {
112
128
 
113
129
  const flyout = open
114
130
  ? createPortal(
115
- <TransitionFlyout style={style} value={value} onChange={onChange} />,
131
+ <TransitionFlyout
132
+ style={{ ...style, ...blockAccentStyle(accentColor) }}
133
+ value={value}
134
+ onChange={onChange}
135
+ colorScheme={colorScheme}
136
+ />,
116
137
  document.body,
117
138
  )
118
139
  : null;
119
140
 
120
141
  return (
121
- <div className="squisq-transition-picker">
142
+ <div
143
+ className="squisq-transition-picker"
144
+ data-theme={colorScheme}
145
+ style={blockAccentStyle(accentColor)}
146
+ >
122
147
  <button
123
148
  ref={triggerRef}
124
149
  type="button"
@@ -151,10 +176,12 @@ function TransitionFlyout({
151
176
  style,
152
177
  value,
153
178
  onChange,
179
+ colorScheme,
154
180
  }: {
155
181
  style: React.CSSProperties;
156
182
  value: TransitionFields;
157
183
  onChange: (next: TransitionFields) => void;
184
+ colorScheme: 'light' | 'dark';
158
185
  }) {
159
186
  const [query, setQuery] = useState('');
160
187
  const searchRef = useRef<HTMLInputElement>(null);
@@ -204,7 +231,13 @@ function TransitionFlyout({
204
231
  };
205
232
 
206
233
  return (
207
- <div id={FLYOUT_ID} className="squisq-transition-flyout" role="menu" style={style}>
234
+ <div
235
+ id={FLYOUT_ID}
236
+ className="squisq-transition-flyout"
237
+ data-theme={colorScheme}
238
+ role="menu"
239
+ style={style}
240
+ >
208
241
  <div className="squisq-transition-flyout-search">
209
242
  <input
210
243
  ref={searchRef}
@@ -306,6 +339,12 @@ function TransitionFlyout({
306
339
  );
307
340
  }
308
341
 
342
+ function blockAccentStyle(accentColor: string | undefined): React.CSSProperties {
343
+ return accentColor
344
+ ? ({ ['--squisq-block-props-accent' as string]: accentColor } as React.CSSProperties)
345
+ : {};
346
+ }
347
+
309
348
  function TransitionOption({
310
349
  entry,
311
350
  selected,
@@ -10,7 +10,7 @@
10
10
  * EditorShell `inlinePreview` prop.
11
11
  */
12
12
 
13
- import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
14
14
  import { useEditorContext, type ThemeInheritance, type LayoutMode } from './EditorContext';
15
15
  import { Icon } from './Icon';
16
16
 
@@ -22,8 +22,8 @@ export function ViewMenuPanel() {
22
22
  setStatusBarVisible,
23
23
  outlineVisible,
24
24
  setOutlineVisible,
25
- blockTagsVisible,
26
- setBlockTagsVisible,
25
+ blockTagVisibility,
26
+ setBlockTagVisibility,
27
27
  themeInheritance,
28
28
  setThemeInheritance,
29
29
  layoutMode,
@@ -56,10 +56,6 @@ export function ViewMenuPanel() {
56
56
  setOutlineVisible(!outlineVisible);
57
57
  }, [outlineVisible, setOutlineVisible]);
58
58
 
59
- const toggleBlockTags = useCallback(() => {
60
- setBlockTagsVisible(!blockTagsVisible);
61
- }, [blockTagsVisible, setBlockTagsVisible]);
62
-
63
59
  return (
64
60
  <div className="squisq-view-menu" ref={containerRef}>
65
61
  <button
@@ -79,7 +75,6 @@ export function ViewMenuPanel() {
79
75
  <div className="squisq-view-menu-popover" role="menu" aria-label="View options">
80
76
  <MenuRadioGroup
81
77
  label="View"
82
- name="layout-mode"
83
78
  value={layoutMode}
84
79
  onChange={(v) => setLayoutMode(v as LayoutMode)}
85
80
  options={[
@@ -95,10 +90,15 @@ export function ViewMenuPanel() {
95
90
  checked={inlinePreviewVisible}
96
91
  onChange={toggleInlinePreview}
97
92
  />
98
- <MenuToggle
93
+ <MenuRadioGroup
99
94
  label="Show block tags"
100
- checked={blockTagsVisible}
101
- onChange={toggleBlockTags}
95
+ value={blockTagVisibility}
96
+ onChange={(v) => setBlockTagVisibility(v as typeof blockTagVisibility)}
97
+ options={[
98
+ { value: 'none', label: 'No inline block tags' },
99
+ { value: 'active', label: 'Selected/hovered block' },
100
+ { value: 'always', label: 'Always show' },
101
+ ]}
102
102
  />
103
103
  <MenuToggle
104
104
  label="Show status bar"
@@ -108,7 +108,6 @@ export function ViewMenuPanel() {
108
108
  <div className="squisq-view-menu-separator" role="separator" />
109
109
  <MenuRadioGroup
110
110
  label="Editor styling from theme"
111
- name="theme-inheritance"
112
111
  value={themeInheritance}
113
112
  onChange={(v) => setThemeInheritance(v as ThemeInheritance)}
114
113
  options={[
@@ -147,17 +146,16 @@ function MenuToggle({
147
146
 
148
147
  function MenuRadioGroup({
149
148
  label,
150
- name,
151
149
  value,
152
150
  onChange,
153
151
  options,
154
152
  }: {
155
153
  label: string;
156
- name: string;
157
154
  value: string;
158
155
  onChange: (next: string) => void;
159
156
  options: readonly { value: string; label: string }[];
160
157
  }) {
158
+ const name = useId();
161
159
  return (
162
160
  <div
163
161
  className="squisq-view-menu-row squisq-view-menu-row--radios"
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * ViewSwitcher
3
3
  *
4
- * Tab bar for switching between Raw, WYSIWYG, and Preview editor views.
4
+ * Tab bar for switching between Write, Source, and Use editor views.
5
5
  */
6
6
 
7
7
  import { useEditorContext, type EditorView } from './EditorContext';
8
8
 
9
9
  const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
10
- { id: 'raw', label: 'Markdown', shortLabel: 'MD', shortcut: '⌘1' },
11
- { id: 'wysiwyg', label: 'Editor', shortcut: '⌘2' },
12
- { id: 'preview', label: 'Preview', shortcut: '⌘3' },
10
+ { id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
11
+ { id: 'raw', label: 'Source', shortcut: '⌘2' },
12
+ { id: 'preview', label: 'Use', shortcut: '⌘3' },
13
13
  ];
14
14
 
15
15
  export interface ViewSwitcherProps {
@@ -13,6 +13,8 @@
13
13
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
14
  import type { CSSProperties } from 'react';
15
15
  import { useEditor, EditorContent } from '@tiptap/react';
16
+ import { Selection } from '@tiptap/pm/state';
17
+ import type { EditorView as ProseMirrorView } from '@tiptap/pm/view';
16
18
  import StarterKit from '@tiptap/starter-kit';
17
19
  import Table from '@tiptap/extension-table';
18
20
  import TableRow from '@tiptap/extension-table-row';
@@ -22,9 +24,19 @@ import TaskList from '@tiptap/extension-task-list';
22
24
  import TaskItem from '@tiptap/extension-task-item';
23
25
  import Link from '@tiptap/extension-link';
24
26
  import Placeholder from '@tiptap/extension-placeholder';
25
- import { resolveFontFamily, FONT_FALLBACKS } from '@bendyline/squisq/schemas';
27
+ import { resolveFontFamily, FONT_FALLBACKS, VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
28
+ import { DEFAULT_THEME, flattenBlocks, markdownToDoc } from '@bendyline/squisq/doc';
29
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
26
30
  import { HeadingWithTemplate } from './TemplateAnnotation';
27
- import { DiagramExtension } from './diagram/DiagramExtension';
31
+ import { AsciiDiagramExtension } from './asciiDiagram/AsciiDiagramExtension';
32
+ import { RepairableDiagramExtension } from './asciiDiagram/RepairableDiagramExtension';
33
+ import { applyRepairCommand } from './asciiDiagram/asciiDiagramCommands';
34
+ import { shouldPasteAsAsciiFence } from './asciiDiagram/asciiPaste';
35
+ import { TreeViewExtension } from './treeview/TreeViewExtension';
36
+ import { shouldPasteAsTreeFence } from './treeview/treePaste';
37
+ import { TimelineViewExtension } from './timeline/TimelineViewExtension';
38
+ import { shouldPasteAsTimelineFence } from './timeline/timelinePaste';
39
+ import { BlockTagActivityExtension } from './blockTagActivity';
28
40
  import { SceneBlockExtension } from './scene/SceneBlockExtension';
29
41
  import { InlineIcon } from './InlineIcon';
30
42
  import { ImageWithMediaProvider } from './ImageNodeView';
@@ -49,6 +61,9 @@ import { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge';
49
61
  import { looksLikeMarkdown } from './detectMarkdown';
50
62
  import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
51
63
  import { usePreviewSettingsOptional } from './PreviewControls';
64
+ import { uploadAndInsertImages } from './wysiwygImageUpload';
65
+
66
+ type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
52
67
 
53
68
  /**
54
69
  * Rotating placeholder prompts shown when the editor is empty. One is
@@ -68,6 +83,12 @@ const EMPTY_PROMPTS = [
68
83
  'Begin at the beginning…',
69
84
  ];
70
85
 
86
+ const BLOCK_TAG_DATA_VALUES = {
87
+ none: 'hidden',
88
+ active: 'active',
89
+ always: 'visible',
90
+ } as const;
91
+
71
92
  function pickEmptyPrompt(): string {
72
93
  return EMPTY_PROMPTS[Math.floor(Math.random() * EMPTY_PROMPTS.length)];
73
94
  }
@@ -106,8 +127,11 @@ export function WysiwygEditor({
106
127
  setTiptapEditor,
107
128
  mediaProvider,
108
129
  mentionProvider,
109
- blockTagsVisible,
130
+ blockTagVisibility,
110
131
  themeInheritance,
132
+ colorScheme,
133
+ bumpMediaRevision,
134
+ sceneTextChannel,
111
135
  } = useEditorContext();
112
136
  // Custom templates inlined in the active doc's frontmatter + the
113
137
  // persist callback that writes a new list back into the source.
@@ -162,7 +186,6 @@ export function WysiwygEditor({
162
186
  useEffect(() => {
163
187
  submitOnEnterRef.current = submitOnEnter;
164
188
  }, [submitOnEnter]);
165
-
166
189
  const editor = useEditor({
167
190
  editable: !readOnly,
168
191
  extensions: [
@@ -174,8 +197,12 @@ export function WysiwygEditor({
174
197
  },
175
198
  }),
176
199
  HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
177
- DiagramExtension,
178
- SceneBlockExtension,
200
+ BlockTagActivityExtension,
201
+ AsciiDiagramExtension.configure({ textChannel: sceneTextChannel }),
202
+ RepairableDiagramExtension.configure({ onRepair: applyRepairCommand }),
203
+ TimelineViewExtension,
204
+ TreeViewExtension,
205
+ SceneBlockExtension.configure({ textChannel: sceneTextChannel }),
179
206
  Table.configure({ resizable: true }),
180
207
  TableRow,
181
208
  TableCell,
@@ -197,8 +224,13 @@ export function WysiwygEditor({
197
224
  content: markdownToTiptap(stripFrontmatter(editorSource).body),
198
225
  onUpdate: ({ editor: ed }) => {
199
226
  if (isExternalUpdate.current) return;
200
- const html = ed.getHTML();
201
- const bodyMd = tiptapToMarkdown(html);
227
+ // Keep the source channel synchronous with Tiptap. Structural actions
228
+ // such as block navigation/addition may run in the very next browser
229
+ // event; deferring this write lets a stale callback serialize the newly
230
+ // selected block into the previous block's range. The expensive full-doc
231
+ // parse remains debounced in EditorContext, so correctness here does not
232
+ // reintroduce parse-on-every-keystroke work.
233
+ const bodyMd = tiptapToMarkdown(ed.getHTML());
202
234
  const newSource = frontmatterRef.current + bodyMd;
203
235
  lastSourceRef.current = newSource;
204
236
  setEditorSource(newSource);
@@ -221,7 +253,9 @@ export function WysiwygEditor({
221
253
  // its container with `display: block` while items are showing
222
254
  // and `display: none` when empty — only short-circuit when
223
255
  // there's actually a candidate to pick.
224
- const popover = document.querySelector<HTMLElement>('.squisq-mention-popover');
256
+ const popover = view.dom
257
+ .closest('.squisq-editor-shell')
258
+ ?.querySelector<HTMLElement>('.squisq-mention-popover');
225
259
  if (popover && popover.style.display !== 'none') {
226
260
  return false;
227
261
  }
@@ -257,12 +291,67 @@ export function WysiwygEditor({
257
291
  const imageFiles = filesFromClipboard(clipboard);
258
292
  if (imageFiles.length > 0 && mediaProviderRef.current) {
259
293
  event.preventDefault();
260
- uploadAndInsertImages(view, imageFiles, mediaProviderRef.current);
294
+ uploadAndInsertImages(view, imageFiles, mediaProviderRef.current, bumpMediaRevision);
261
295
  return true;
262
296
  }
263
297
 
264
298
  const text = clipboard.getData('text/plain');
265
- if (!text || !looksLikeMarkdown(text)) return false;
299
+ if (!text) return false;
300
+
301
+ // Bare (unfenced) ASCII diagram art → verbatim into a fresh code
302
+ // block, which AsciiDiagramExtension turns into an interactive
303
+ // canvas. Must run BEFORE the markdown branch: `| x |` art rows
304
+ // false-positive as GFM table rows and would be mangled.
305
+ if (shouldPasteAsAsciiFence(text)) {
306
+ const codeBlockType = view.state.schema.nodes.codeBlock;
307
+ if (codeBlockType) {
308
+ event.preventDefault();
309
+ // Tag the fence `diagram` so its identity survives a later
310
+ // flatten → markdown → re-import round-trip (the language class
311
+ // round-trips; fence meta does not).
312
+ view.dispatch(
313
+ view.state.tr.replaceSelectionWith(
314
+ codeBlockType.create({ language: 'diagram' }, view.state.schema.text(text)),
315
+ ),
316
+ );
317
+ return true;
318
+ }
319
+ }
320
+
321
+ // Bare (unfenced) marker rails → an explicit timeline code block.
322
+ // Run before the tree gate because timeline callout elbows can look
323
+ // like nested tree connectors to a deliberately lenient parser.
324
+ if (shouldPasteAsTimelineFence(text)) {
325
+ const codeBlockType = view.state.schema.nodes.codeBlock;
326
+ if (codeBlockType) {
327
+ event.preventDefault();
328
+ view.dispatch(
329
+ view.state.tr.replaceSelectionWith(
330
+ codeBlockType.create({ language: 'timeline' }, view.state.schema.text(text)),
331
+ ),
332
+ );
333
+ return true;
334
+ }
335
+ }
336
+
337
+ // Bare (unfenced) ASCII tree art → a fresh code block that the
338
+ // TreeViewExtension turns into an outline. After the diagram gate,
339
+ // before markdown (the `├──` lines must not be mangled as prose).
340
+ if (shouldPasteAsTreeFence(text)) {
341
+ const codeBlockType = view.state.schema.nodes.codeBlock;
342
+ if (codeBlockType) {
343
+ event.preventDefault();
344
+ // Tag the fence `tree` so its identity survives round-trips.
345
+ view.dispatch(
346
+ view.state.tr.replaceSelectionWith(
347
+ codeBlockType.create({ language: 'tree' }, view.state.schema.text(text)),
348
+ ),
349
+ );
350
+ return true;
351
+ }
352
+ }
353
+
354
+ if (!looksLikeMarkdown(text)) return false;
266
355
  const html = markdownToTiptap(text);
267
356
  if (!html) return false;
268
357
  event.preventDefault();
@@ -326,7 +415,7 @@ export function WysiwygEditor({
326
415
 
327
416
  event.preventDefault();
328
417
  moveSelectionToDropPoint(view, event);
329
- uploadAndInsertImages(view, imageFiles, mediaProviderRef.current);
418
+ uploadAndInsertImages(view, imageFiles, mediaProviderRef.current, bumpMediaRevision);
330
419
  return true;
331
420
  },
332
421
  },
@@ -366,6 +455,16 @@ export function WysiwygEditor({
366
455
  templateParams: string | null;
367
456
  } | null>(null);
368
457
 
458
+ const closeBadgeMenu = useCallback(() => {
459
+ setBadgeMenu(null);
460
+ // The portaled gallery moves focus into its search field. Restore the
461
+ // document's preserved ProseMirror selection after React has unmounted the
462
+ // dialog, so the caret resumes blinking where the author left it.
463
+ requestAnimationFrame(() => {
464
+ if (editor && !editor.isDestroyed) editor.commands.focus();
465
+ });
466
+ }, [editor]);
467
+
369
468
  useEffect(() => {
370
469
  if (!editor) return;
371
470
  const root = containerRef.current;
@@ -456,9 +555,10 @@ export function WysiwygEditor({
456
555
  isExternalUpdate.current = false;
457
556
  }, [editorSource, editor]);
458
557
 
459
- // Match the WYSIWYG editor's appearance to the active Squisq theme
460
- // when one is set in frontmatter or picked in the preview dropdown.
461
- // Driven by the View menu's "Theme inheritance" setting:
558
+ // Match the WYSIWYG editor's appearance to the active Squisq theme when one
559
+ // is set in frontmatter or picked in the preview dropdown. The block-props
560
+ // affordance always carries the theme's primary as UI accent (like the
561
+ // outline); document-content inheritance is driven by the View menu setting:
462
562
  // - 'none' → don't inherit anything
463
563
  // - 'fonts' → body + heading fonts only (historical default)
464
564
  // - 'fonts-colors' → fonts plus the theme's canvas / text colors
@@ -467,9 +567,38 @@ export function WysiwygEditor({
467
567
  // a PreviewSettingsProvider in scope).
468
568
  const previewSettings = usePreviewSettingsOptional();
469
569
  const activeTheme = previewSettings?.activeTheme;
570
+ const badgePreviewSource = useMemo(() => {
571
+ if (!badgeMenu) return undefined;
572
+ try {
573
+ const previewDoc = markdownToDoc(parseMarkdown(editorSource), { autoTemplates: false });
574
+ const block = flattenBlocks(previewDoc.blocks)[badgeMenu.headingIndex];
575
+ if (!block) return undefined;
576
+ return {
577
+ block,
578
+ theme: previewSettings?.activeTheme ?? DEFAULT_THEME,
579
+ viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
580
+ basePath: '/',
581
+ mediaProvider,
582
+ customTemplates: docTemplates,
583
+ };
584
+ } catch {
585
+ return undefined;
586
+ }
587
+ }, [
588
+ badgeMenu,
589
+ docTemplates,
590
+ editorSource,
591
+ mediaProvider,
592
+ previewSettings?.activeTheme,
593
+ previewSettings?.activeViewport,
594
+ ]);
470
595
  const themeStyle = useMemo<CSSProperties>(() => {
471
- if (themeInheritance === 'none' || !activeTheme) return {};
596
+ if (!activeTheme) return {};
472
597
  const out: Record<string, string> = {
598
+ '--squisq-block-props-accent': activeTheme.colors.primary,
599
+ };
600
+ if (themeInheritance === 'none') return out as CSSProperties;
601
+ Object.assign(out, {
473
602
  '--squisq-theme-body-font': resolveFontFamily(
474
603
  activeTheme.typography.bodyFont,
475
604
  FONT_FALLBACKS.sans,
@@ -478,7 +607,7 @@ export function WysiwygEditor({
478
607
  activeTheme.typography.titleFont,
479
608
  FONT_FALLBACKS.sans,
480
609
  ),
481
- };
610
+ });
482
611
  if (themeInheritance === 'fonts-colors') {
483
612
  const colors = activeTheme.colors;
484
613
  out['--squisq-theme-bg'] = colors.background;
@@ -498,7 +627,7 @@ export function WysiwygEditor({
498
627
  className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
499
628
  style={{ width: '100%', height: '100%', overflow: 'auto', ...themeStyle }}
500
629
  data-testid="wysiwyg-container"
501
- data-block-tags={blockTagsVisible ? 'visible' : 'hidden'}
630
+ data-block-tags={BLOCK_TAG_DATA_VALUES[blockTagVisibility]}
502
631
  data-theme-inheritance={themeInheritance}
503
632
  ref={containerRef}
504
633
  >
@@ -507,6 +636,7 @@ export function WysiwygEditor({
507
636
  <TemplateBadgePopover
508
637
  anchorRect={badgeMenu.rect}
509
638
  value={badgeMenu.template}
639
+ colorScheme={colorScheme}
510
640
  recommended={(() => {
511
641
  // `headingIndex` is counted within the mounted Tiptap doc, which
512
642
  // reflects `editorSource` — the active block's slice in block
@@ -516,6 +646,7 @@ export function WysiwygEditor({
516
646
  const profile = profileBlockContents(slice);
517
647
  return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
518
648
  })()}
649
+ previewSource={badgePreviewSource}
519
650
  onOpenDesigner={() => {
520
651
  setBadgeMenu(null);
521
652
  setDesignerState({});
@@ -528,7 +659,7 @@ export function WysiwygEditor({
528
659
  });
529
660
  editor.view.dispatch(tr);
530
661
  }}
531
- onClose={() => setBadgeMenu(null)}
662
+ onClose={closeBadgeMenu}
532
663
  />
533
664
  )}
534
665
  {propsMenu && (
@@ -536,6 +667,8 @@ export function WysiwygEditor({
536
667
  anchorRect={propsMenu.rect}
537
668
  blockAttrs={propsMenu.blockAttrs}
538
669
  templateParams={propsMenu.templateParams}
670
+ colorScheme={colorScheme}
671
+ accentColor={activeTheme?.colors.primary}
539
672
  onChange={(nextInner) => {
540
673
  if (!editor) return;
541
674
  const current = editor.state.doc.nodeAt(propsMenu.headingPos);
@@ -546,6 +679,17 @@ export function WysiwygEditor({
546
679
  });
547
680
  editor.view.dispatch(tr);
548
681
  }}
682
+ onAnnotationChange={(next) => {
683
+ if (!editor) return;
684
+ const current = editor.state.doc.nodeAt(propsMenu.headingPos);
685
+ if (!current || current.type.name !== 'heading') return;
686
+ const tr = editor.state.tr.setNodeMarkup(propsMenu.headingPos, undefined, {
687
+ ...current.attrs,
688
+ dataBlockAttrs: next.blockAttrsInner,
689
+ dataTemplateParams: next.templateParams,
690
+ });
691
+ editor.view.dispatch(tr);
692
+ }}
549
693
  onClose={() => setPropsMenu(null)}
550
694
  />
551
695
  )}
@@ -555,6 +699,7 @@ export function WysiwygEditor({
555
699
  onSave={handleDesignerSave}
556
700
  onClose={() => setDesignerState(null)}
557
701
  mediaProvider={mediaProvider}
702
+ colorScheme={colorScheme}
558
703
  />
559
704
  )}
560
705
  </div>
@@ -621,41 +766,8 @@ function filesFromClipboard(clipboard: DataTransfer): File[] {
621
766
  return files;
622
767
  }
623
768
 
624
- /**
625
- * Upload image files to the MediaProvider and insert <img> nodes at the
626
- * current selection. Inserts a placeholder name when files lack one
627
- * (e.g., screenshots from the system clipboard).
628
- */
629
- async function uploadAndInsertImages(
630
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
631
- view: any,
632
- files: File[],
633
- mediaProvider: import('@bendyline/squisq/schemas').MediaProvider,
634
- ): Promise<void> {
635
- for (const file of files) {
636
- try {
637
- const buffer = await file.arrayBuffer();
638
- const mimeType = file.type || 'image/png';
639
- const name =
640
- file.name && file.name !== 'image.png'
641
- ? file.name
642
- : `pasted-${uniquePasteToken()}.${extFromMime(mimeType)}`;
643
- const relativePath = await mediaProvider.addMedia(name, buffer, mimeType);
644
- const altText = name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
645
- insertImageNode(view, relativePath, altText);
646
- } catch (err) {
647
- console.error('Failed to upload dropped image:', err);
648
- }
649
- }
650
- }
651
-
652
769
  /** Insert an image node at the current selection using the schema image type. */
653
- function insertImageNode(
654
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
655
- view: any,
656
- src: string,
657
- alt: string,
658
- ): void {
770
+ function insertImageNode(view: ImageMutationView, src: string, alt: string): void {
659
771
  const { schema } = view.state;
660
772
  const imageType = schema.nodes.image;
661
773
  if (!imageType) return;
@@ -665,50 +777,13 @@ function insertImageNode(
665
777
  }
666
778
 
667
779
  /** Move the selection to the document position under the drop event's coordinates. */
668
- function moveSelectionToDropPoint(
669
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
670
- view: any,
671
- event: DragEvent,
672
- ): void {
780
+ function moveSelectionToDropPoint(view: ProseMirrorView, event: DragEvent): void {
673
781
  const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
674
782
  if (!coords) return;
675
- const tr = view.state.tr.setSelection(
676
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
677
- (view.state.selection.constructor as any).near(view.state.doc.resolve(coords.pos)),
678
- );
783
+ const tr = view.state.tr.setSelection(Selection.near(view.state.doc.resolve(coords.pos)));
679
784
  view.dispatch(tr);
680
785
  }
681
786
 
682
- /**
683
- * Produce a unique token for a pasted-file name. `Date.now()` alone can
684
- * collide when a user pastes several clipboard images in the same tick
685
- * (multi-image paste from a screenshot grid, for example), which would make
686
- * `MediaProvider.addMedia` overwrite or reject later entries. Prefer
687
- * `crypto.randomUUID()` when available and fall back to a counter so the
688
- * helper stays pure-JS-everywhere.
689
- */
690
- let pasteCounter = 0;
691
- function uniquePasteToken(): string {
692
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
693
- return crypto.randomUUID();
694
- }
695
- pasteCounter = (pasteCounter + 1) % 1_000_000;
696
- return `${Date.now()}-${pasteCounter.toString(36)}`;
697
- }
698
-
699
- function extFromMime(mime: string): string {
700
- const map: Record<string, string> = {
701
- 'image/png': 'png',
702
- 'image/jpeg': 'jpg',
703
- 'image/jpg': 'jpg',
704
- 'image/gif': 'gif',
705
- 'image/webp': 'webp',
706
- 'image/svg+xml': 'svg',
707
- 'image/avif': 'avif',
708
- };
709
- return map[mime.toLowerCase()] ?? 'png';
710
- }
711
-
712
787
  /**
713
788
  * Hook to access the Tiptap editor instance for toolbar commands.
714
789
  * The WysiwygEditor must be mounted as a sibling or descendant.
@@ -0,0 +1,33 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { BlockPropertiesPopover } from '../BlockPropertiesPopover';
4
+ import { TRANSITION_FLYOUT_PORTAL_ID } from '../TransitionPicker';
5
+
6
+ describe('BlockPropertiesPopover theme propagation', () => {
7
+ it('copies dark mode and the document accent onto both portal roots', async () => {
8
+ render(
9
+ <BlockPropertiesPopover
10
+ anchorRect={new DOMRect(20, 20, 20, 20)}
11
+ blockAttrs={null}
12
+ templateParams={null}
13
+ colorScheme="dark"
14
+ accentColor="#c2410c"
15
+ onChange={vi.fn()}
16
+ onAnnotationChange={vi.fn()}
17
+ onClose={vi.fn()}
18
+ />,
19
+ );
20
+
21
+ const panel = screen.getByRole('dialog', { name: 'Block properties' });
22
+ expect(panel.getAttribute('data-theme')).toBe('dark');
23
+ expect(panel.style.getPropertyValue('--squisq-block-props-accent')).toBe('#c2410c');
24
+
25
+ fireEvent.click(screen.getByRole('button', { name: /Transition:/ }));
26
+
27
+ await waitFor(() => {
28
+ const flyout = document.getElementById(TRANSITION_FLYOUT_PORTAL_ID);
29
+ expect(flyout?.getAttribute('data-theme')).toBe('dark');
30
+ expect(flyout?.style.getPropertyValue('--squisq-block-props-accent')).toBe('#c2410c');
31
+ });
32
+ });
33
+ });