@bendyline/squisq-editor-react 1.6.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -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"
@@ -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';
@@ -26,7 +28,15 @@ import { resolveFontFamily, FONT_FALLBACKS, VIEWPORT_PRESETS } from '@bendyline/
26
28
  import { DEFAULT_THEME, flattenBlocks, markdownToDoc } from '@bendyline/squisq/doc';
27
29
  import { parseMarkdown } from '@bendyline/squisq/markdown';
28
30
  import { HeadingWithTemplate } from './TemplateAnnotation';
29
- 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';
30
40
  import { SceneBlockExtension } from './scene/SceneBlockExtension';
31
41
  import { InlineIcon } from './InlineIcon';
32
42
  import { ImageWithMediaProvider } from './ImageNodeView';
@@ -51,6 +61,9 @@ import { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge';
51
61
  import { looksLikeMarkdown } from './detectMarkdown';
52
62
  import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
53
63
  import { usePreviewSettingsOptional } from './PreviewControls';
64
+ import { uploadAndInsertImages } from './wysiwygImageUpload';
65
+
66
+ type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
54
67
 
55
68
  /**
56
69
  * Rotating placeholder prompts shown when the editor is empty. One is
@@ -70,6 +83,12 @@ const EMPTY_PROMPTS = [
70
83
  'Begin at the beginning…',
71
84
  ];
72
85
 
86
+ const BLOCK_TAG_DATA_VALUES = {
87
+ none: 'hidden',
88
+ active: 'active',
89
+ always: 'visible',
90
+ } as const;
91
+
73
92
  function pickEmptyPrompt(): string {
74
93
  return EMPTY_PROMPTS[Math.floor(Math.random() * EMPTY_PROMPTS.length)];
75
94
  }
@@ -108,10 +127,11 @@ export function WysiwygEditor({
108
127
  setTiptapEditor,
109
128
  mediaProvider,
110
129
  mentionProvider,
111
- blockTagsVisible,
130
+ blockTagVisibility,
112
131
  themeInheritance,
113
132
  colorScheme,
114
133
  bumpMediaRevision,
134
+ sceneTextChannel,
115
135
  } = useEditorContext();
116
136
  // Custom templates inlined in the active doc's frontmatter + the
117
137
  // persist callback that writes a new list back into the source.
@@ -166,7 +186,6 @@ export function WysiwygEditor({
166
186
  useEffect(() => {
167
187
  submitOnEnterRef.current = submitOnEnter;
168
188
  }, [submitOnEnter]);
169
-
170
189
  const editor = useEditor({
171
190
  editable: !readOnly,
172
191
  extensions: [
@@ -178,8 +197,12 @@ export function WysiwygEditor({
178
197
  },
179
198
  }),
180
199
  HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
181
- DiagramExtension,
182
- SceneBlockExtension,
200
+ BlockTagActivityExtension,
201
+ AsciiDiagramExtension.configure({ textChannel: sceneTextChannel }),
202
+ RepairableDiagramExtension.configure({ onRepair: applyRepairCommand }),
203
+ TimelineViewExtension,
204
+ TreeViewExtension,
205
+ SceneBlockExtension.configure({ textChannel: sceneTextChannel }),
183
206
  Table.configure({ resizable: true }),
184
207
  TableRow,
185
208
  TableCell,
@@ -201,8 +224,13 @@ export function WysiwygEditor({
201
224
  content: markdownToTiptap(stripFrontmatter(editorSource).body),
202
225
  onUpdate: ({ editor: ed }) => {
203
226
  if (isExternalUpdate.current) return;
204
- const html = ed.getHTML();
205
- 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());
206
234
  const newSource = frontmatterRef.current + bodyMd;
207
235
  lastSourceRef.current = newSource;
208
236
  setEditorSource(newSource);
@@ -225,7 +253,9 @@ export function WysiwygEditor({
225
253
  // its container with `display: block` while items are showing
226
254
  // and `display: none` when empty — only short-circuit when
227
255
  // there's actually a candidate to pick.
228
- const popover = document.querySelector<HTMLElement>('.squisq-mention-popover');
256
+ const popover = view.dom
257
+ .closest('.squisq-editor-shell')
258
+ ?.querySelector<HTMLElement>('.squisq-mention-popover');
229
259
  if (popover && popover.style.display !== 'none') {
230
260
  return false;
231
261
  }
@@ -266,7 +296,62 @@ export function WysiwygEditor({
266
296
  }
267
297
 
268
298
  const text = clipboard.getData('text/plain');
269
- 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;
270
355
  const html = markdownToTiptap(text);
271
356
  if (!html) return false;
272
357
  event.preventDefault();
@@ -370,6 +455,16 @@ export function WysiwygEditor({
370
455
  templateParams: string | null;
371
456
  } | null>(null);
372
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
+
373
468
  useEffect(() => {
374
469
  if (!editor) return;
375
470
  const root = containerRef.current;
@@ -460,9 +555,10 @@ export function WysiwygEditor({
460
555
  isExternalUpdate.current = false;
461
556
  }, [editorSource, editor]);
462
557
 
463
- // Match the WYSIWYG editor's appearance to the active Squisq theme
464
- // when one is set in frontmatter or picked in the preview dropdown.
465
- // 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:
466
562
  // - 'none' → don't inherit anything
467
563
  // - 'fonts' → body + heading fonts only (historical default)
468
564
  // - 'fonts-colors' → fonts plus the theme's canvas / text colors
@@ -483,20 +579,26 @@ export function WysiwygEditor({
483
579
  viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
484
580
  basePath: '/',
485
581
  mediaProvider,
582
+ customTemplates: docTemplates,
486
583
  };
487
584
  } catch {
488
585
  return undefined;
489
586
  }
490
587
  }, [
491
588
  badgeMenu,
589
+ docTemplates,
492
590
  editorSource,
493
591
  mediaProvider,
494
592
  previewSettings?.activeTheme,
495
593
  previewSettings?.activeViewport,
496
594
  ]);
497
595
  const themeStyle = useMemo<CSSProperties>(() => {
498
- if (themeInheritance === 'none' || !activeTheme) return {};
596
+ if (!activeTheme) return {};
499
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, {
500
602
  '--squisq-theme-body-font': resolveFontFamily(
501
603
  activeTheme.typography.bodyFont,
502
604
  FONT_FALLBACKS.sans,
@@ -505,7 +607,7 @@ export function WysiwygEditor({
505
607
  activeTheme.typography.titleFont,
506
608
  FONT_FALLBACKS.sans,
507
609
  ),
508
- };
610
+ });
509
611
  if (themeInheritance === 'fonts-colors') {
510
612
  const colors = activeTheme.colors;
511
613
  out['--squisq-theme-bg'] = colors.background;
@@ -525,7 +627,7 @@ export function WysiwygEditor({
525
627
  className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
526
628
  style={{ width: '100%', height: '100%', overflow: 'auto', ...themeStyle }}
527
629
  data-testid="wysiwyg-container"
528
- data-block-tags={blockTagsVisible ? 'visible' : 'hidden'}
630
+ data-block-tags={BLOCK_TAG_DATA_VALUES[blockTagVisibility]}
529
631
  data-theme-inheritance={themeInheritance}
530
632
  ref={containerRef}
531
633
  >
@@ -557,7 +659,7 @@ export function WysiwygEditor({
557
659
  });
558
660
  editor.view.dispatch(tr);
559
661
  }}
560
- onClose={() => setBadgeMenu(null)}
662
+ onClose={closeBadgeMenu}
561
663
  />
562
664
  )}
563
665
  {propsMenu && (
@@ -565,6 +667,8 @@ export function WysiwygEditor({
565
667
  anchorRect={propsMenu.rect}
566
668
  blockAttrs={propsMenu.blockAttrs}
567
669
  templateParams={propsMenu.templateParams}
670
+ colorScheme={colorScheme}
671
+ accentColor={activeTheme?.colors.primary}
568
672
  onChange={(nextInner) => {
569
673
  if (!editor) return;
570
674
  const current = editor.state.doc.nodeAt(propsMenu.headingPos);
@@ -595,6 +699,7 @@ export function WysiwygEditor({
595
699
  onSave={handleDesignerSave}
596
700
  onClose={() => setDesignerState(null)}
597
701
  mediaProvider={mediaProvider}
702
+ colorScheme={colorScheme}
598
703
  />
599
704
  )}
600
705
  </div>
@@ -661,43 +766,8 @@ function filesFromClipboard(clipboard: DataTransfer): File[] {
661
766
  return files;
662
767
  }
663
768
 
664
- /**
665
- * Upload image files to the MediaProvider and insert <img> nodes at the
666
- * current selection. Inserts a placeholder name when files lack one
667
- * (e.g., screenshots from the system clipboard).
668
- */
669
- async function uploadAndInsertImages(
670
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
671
- view: any,
672
- files: File[],
673
- mediaProvider: import('@bendyline/squisq/schemas').MediaProvider,
674
- onMediaUploaded?: () => void,
675
- ): Promise<void> {
676
- for (const file of files) {
677
- try {
678
- const buffer = await file.arrayBuffer();
679
- const mimeType = file.type || 'image/png';
680
- const name =
681
- file.name && file.name !== 'image.png'
682
- ? file.name
683
- : `pasted-${uniquePasteToken()}.${extFromMime(mimeType)}`;
684
- const relativePath = await mediaProvider.addMedia(name, buffer, mimeType);
685
- const altText = name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
686
- insertImageNode(view, relativePath, altText);
687
- onMediaUploaded?.();
688
- } catch (err) {
689
- console.error('Failed to upload dropped image:', err);
690
- }
691
- }
692
- }
693
-
694
769
  /** Insert an image node at the current selection using the schema image type. */
695
- function insertImageNode(
696
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
697
- view: any,
698
- src: string,
699
- alt: string,
700
- ): void {
770
+ function insertImageNode(view: ImageMutationView, src: string, alt: string): void {
701
771
  const { schema } = view.state;
702
772
  const imageType = schema.nodes.image;
703
773
  if (!imageType) return;
@@ -707,50 +777,13 @@ function insertImageNode(
707
777
  }
708
778
 
709
779
  /** Move the selection to the document position under the drop event's coordinates. */
710
- function moveSelectionToDropPoint(
711
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
712
- view: any,
713
- event: DragEvent,
714
- ): void {
780
+ function moveSelectionToDropPoint(view: ProseMirrorView, event: DragEvent): void {
715
781
  const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
716
782
  if (!coords) return;
717
- const tr = view.state.tr.setSelection(
718
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
719
- (view.state.selection.constructor as any).near(view.state.doc.resolve(coords.pos)),
720
- );
783
+ const tr = view.state.tr.setSelection(Selection.near(view.state.doc.resolve(coords.pos)));
721
784
  view.dispatch(tr);
722
785
  }
723
786
 
724
- /**
725
- * Produce a unique token for a pasted-file name. `Date.now()` alone can
726
- * collide when a user pastes several clipboard images in the same tick
727
- * (multi-image paste from a screenshot grid, for example), which would make
728
- * `MediaProvider.addMedia` overwrite or reject later entries. Prefer
729
- * `crypto.randomUUID()` when available and fall back to a counter so the
730
- * helper stays pure-JS-everywhere.
731
- */
732
- let pasteCounter = 0;
733
- function uniquePasteToken(): string {
734
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
735
- return crypto.randomUUID();
736
- }
737
- pasteCounter = (pasteCounter + 1) % 1_000_000;
738
- return `${Date.now()}-${pasteCounter.toString(36)}`;
739
- }
740
-
741
- function extFromMime(mime: string): string {
742
- const map: Record<string, string> = {
743
- 'image/png': 'png',
744
- 'image/jpeg': 'jpg',
745
- 'image/jpg': 'jpg',
746
- 'image/gif': 'gif',
747
- 'image/webp': 'webp',
748
- 'image/svg+xml': 'svg',
749
- 'image/avif': 'avif',
750
- };
751
- return map[mime.toLowerCase()] ?? 'png';
752
- }
753
-
754
787
  /**
755
788
  * Hook to access the Tiptap editor instance for toolbar commands.
756
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
+ });
@@ -0,0 +1,183 @@
1
+ import { Editor } from '@tiptap/core';
2
+ import StarterKit from '@tiptap/starter-kit';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import {
5
+ BlockTagActivityExtension,
6
+ BLOCK_TAG_HOVERED_CLASS,
7
+ BLOCK_TAG_SELECTED_CLASS,
8
+ } from '../blockTagActivity';
9
+ import { HeadingWithTemplate } from '../TemplateAnnotation';
10
+ import { markdownToTiptap } from '../tiptapBridge';
11
+
12
+ const MARKDOWN = `# First block {[title]}
13
+
14
+ First body.
15
+
16
+ - Outer item with **nested inline target**
17
+
18
+ # Second block {[quote]}
19
+
20
+ Second body.
21
+ `;
22
+
23
+ const editors: Editor[] = [];
24
+ const editorHosts: HTMLElement[] = [];
25
+
26
+ function makeEditor(): Editor {
27
+ const element = document.createElement('div');
28
+ document.body.appendChild(element);
29
+ editorHosts.push(element);
30
+
31
+ const editor = new Editor({
32
+ element,
33
+ extensions: [
34
+ StarterKit.configure({ heading: false }),
35
+ HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
36
+ BlockTagActivityExtension,
37
+ ],
38
+ content: markdownToTiptap(MARKDOWN),
39
+ });
40
+ editors.push(editor);
41
+ return editor;
42
+ }
43
+
44
+ function positionInsideTopLevelNode(editor: Editor, text: string): number {
45
+ let position: number | null = null;
46
+ editor.state.doc.forEach((node, offset) => {
47
+ if (position === null && node.textContent === text) position = offset + 1;
48
+ });
49
+ if (position === null) throw new Error(`Top-level node not found: ${text}`);
50
+ return position;
51
+ }
52
+
53
+ function headings(editor: Editor): HTMLElement[] {
54
+ return Array.from(editor.view.dom.children).filter((element): element is HTMLElement =>
55
+ /^H[1-6]$/.test(element.tagName),
56
+ );
57
+ }
58
+
59
+ function elementWithExactText(editor: Editor, selector: string, text: string): HTMLElement {
60
+ const element = Array.from(editor.view.dom.querySelectorAll<HTMLElement>(selector)).find(
61
+ (candidate) => candidate.textContent === text,
62
+ );
63
+ if (!element) throw new Error(`Element not found: ${selector} containing ${text}`);
64
+ return element;
65
+ }
66
+
67
+ afterEach(() => {
68
+ for (const editor of editors.splice(0)) editor.destroy();
69
+ for (const host of editorHosts.splice(0)) host.remove();
70
+ });
71
+
72
+ function waitForDomReconciliation(): Promise<void> {
73
+ return new Promise((resolve) => setTimeout(resolve, 0));
74
+ }
75
+
76
+ describe('block-tag activity', () => {
77
+ it('decorates the initial selection before the editor view settles', async () => {
78
+ const editor = makeEditor();
79
+ await waitForDomReconciliation();
80
+
81
+ const [firstHeading] = headings(editor);
82
+ expect(firstHeading.isConnected).toBe(true);
83
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
84
+ });
85
+
86
+ it('keeps cursor and hover decorations through DOM reconciliation', async () => {
87
+ const editor = makeEditor();
88
+ editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'First body.'));
89
+ await waitForDomReconciliation();
90
+
91
+ const [firstHeading, secondHeading] = headings(editor);
92
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
93
+ expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
94
+
95
+ const secondBody = elementWithExactText(editor, 'p', 'Second body.');
96
+ secondBody.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
97
+ await waitForDomReconciliation();
98
+
99
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
100
+ expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
101
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
102
+ expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
103
+
104
+ editor.view.dom.dispatchEvent(new MouseEvent('mouseleave'));
105
+ await waitForDomReconciliation();
106
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
107
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
108
+
109
+ editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'Second body.'));
110
+ await waitForDomReconciliation();
111
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
112
+ expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
113
+ });
114
+
115
+ it('maps a nested list target to the heading that owns the list', async () => {
116
+ const editor = makeEditor();
117
+ editor.commands.setTextSelection(positionInsideTopLevelNode(editor, 'Second body.'));
118
+ await waitForDomReconciliation();
119
+
120
+ const [firstHeading, secondHeading] = headings(editor);
121
+ const nestedTarget = elementWithExactText(editor, 'strong', 'nested inline target');
122
+ nestedTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
123
+ await waitForDomReconciliation();
124
+
125
+ expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
126
+ expect(firstHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(false);
127
+ expect(secondHeading.classList.contains(BLOCK_TAG_SELECTED_CLASS)).toBe(true);
128
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
129
+ });
130
+
131
+ it('keeps the owning block hovered across blank space between its nodes', async () => {
132
+ const editor = makeEditor();
133
+ await waitForDomReconciliation();
134
+
135
+ const [firstHeading, secondHeading] = headings(editor);
136
+ const topLevelChildren = Array.from(editor.view.dom.children);
137
+ const firstHeadingIndex = topLevelChildren.indexOf(firstHeading);
138
+ const secondHeadingIndex = topLevelChildren.indexOf(secondHeading);
139
+
140
+ topLevelChildren.forEach((element, index) => {
141
+ const top =
142
+ index < secondHeadingIndex
143
+ ? 10 + (index - firstHeadingIndex) * 25
144
+ : 120 + (index - secondHeadingIndex) * 25;
145
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
146
+ top,
147
+ bottom: top + 18,
148
+ } as DOMRect);
149
+ });
150
+
151
+ editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 105 }));
152
+ await waitForDomReconciliation();
153
+
154
+ expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
155
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
156
+
157
+ editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 145 }));
158
+ await waitForDomReconciliation();
159
+
160
+ expect(firstHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
161
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(true);
162
+ });
163
+
164
+ it('clears block hover below the final content node', async () => {
165
+ const editor = makeEditor();
166
+ await waitForDomReconciliation();
167
+
168
+ const [, secondHeading] = headings(editor);
169
+ const topLevelChildren = Array.from(editor.view.dom.children);
170
+ topLevelChildren.forEach((element, index) => {
171
+ const top = index * 25;
172
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
173
+ top,
174
+ bottom: top + 18,
175
+ } as DOMRect);
176
+ });
177
+
178
+ editor.view.dom.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 1000 }));
179
+ await waitForDomReconciliation();
180
+
181
+ expect(secondHeading.classList.contains(BLOCK_TAG_HOVERED_CLASS)).toBe(false);
182
+ });
183
+ });