@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
@@ -8,7 +8,15 @@
8
8
  * lives in `useHeadingLayout`.
9
9
  */
10
10
 
11
- import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
+ import {
12
+ type CSSProperties,
13
+ type DragEvent as ReactDragEvent,
14
+ useCallback,
15
+ useEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ } from 'react';
12
20
  import type { Block } from '@bendyline/squisq/schemas';
13
21
  import { flattenBlocks, hasTemplate } from '@bendyline/squisq/doc';
14
22
  import { extractPlainText } from '@bendyline/squisq/markdown';
@@ -16,6 +24,7 @@ import { useEditorContext } from './EditorContext';
16
24
  import { templateLabel } from './TemplatePicker';
17
25
  import { useHeadingLayout } from './useHeadingLayout';
18
26
  import { usePreviewSettingsOptional } from './PreviewControls';
27
+ import { moveHeadingSectionInSource, type OutlineDropPlacement } from './outlineSource';
19
28
 
20
29
  /**
21
30
  * Responsive default width for the outline pane, used when no fixed `width`
@@ -38,13 +47,34 @@ export interface OutlinePanelProps {
38
47
  width?: number;
39
48
  /** Optional CSS class for the outer container. */
40
49
  className?: string;
50
+ /** Disable heading-level changes and section reordering. */
51
+ readOnly?: boolean;
41
52
  }
42
53
 
43
- export function OutlinePanel({ width, className }: OutlinePanelProps) {
54
+ interface OutlineSectionRef {
55
+ blockId: string;
56
+ headingLine: number;
57
+ depth: number;
58
+ parentId: string | null;
59
+ }
60
+
61
+ interface ActiveOutlineDrag extends OutlineSectionRef {
62
+ sourceAtStart: string;
63
+ }
64
+
65
+ interface OutlineDropTarget {
66
+ blockId: string;
67
+ placement: OutlineDropPlacement;
68
+ }
69
+
70
+ const OUTLINE_DRAG_MIME = 'application/x-squisq-outline-section';
71
+
72
+ export function OutlinePanel({ width, className, readOnly = false }: OutlinePanelProps) {
44
73
  const {
45
74
  doc,
46
75
  markdownSource,
47
76
  setMarkdownSource,
77
+ isParsing,
48
78
  layoutMode,
49
79
  goToBlockByLine,
50
80
  activeBlockStartLine,
@@ -52,6 +82,9 @@ export function OutlinePanel({ width, className }: OutlinePanelProps) {
52
82
  const paneRef = useRef<HTMLElement | null>(null);
53
83
  const { scrollToBlock } = useHeadingLayout(paneRef);
54
84
  const cursorActiveId = useActiveOutlineBlockId();
85
+ const activeDragRef = useRef<ActiveOutlineDrag | null>(null);
86
+ const [draggedBlockId, setDraggedBlockId] = useState<string | null>(null);
87
+ const [dropTarget, setDropTarget] = useState<OutlineDropTarget | null>(null);
55
88
 
56
89
  // In block-at-a-time mode there's no live cursor across the whole document
57
90
  // (only the active block is mounted), so the highlight follows the card:
@@ -87,12 +120,94 @@ export function OutlinePanel({ width, className }: OutlinePanelProps) {
87
120
  // Both editor surfaces resync from `markdownSource` automatically.
88
121
  const changeHeadingLevel = useCallback(
89
122
  (block: Block, delta: number) => {
123
+ if (readOnly || isParsing) return;
90
124
  const line = block.sourceHeading?.position?.start.line;
91
125
  if (typeof line !== 'number') return;
92
126
  const next = bumpHeadingLevelInSource(markdownSource, line, delta);
93
127
  if (next != null) setMarkdownSource(next);
94
128
  },
95
- [markdownSource, setMarkdownSource],
129
+ [isParsing, markdownSource, readOnly, setMarkdownSource],
130
+ );
131
+
132
+ const clearDragState = useCallback(() => {
133
+ activeDragRef.current = null;
134
+ setDraggedBlockId(null);
135
+ setDropTarget(null);
136
+ }, []);
137
+
138
+ const handleDragStart = useCallback(
139
+ (event: ReactDragEvent<HTMLElement>, section: OutlineSectionRef) => {
140
+ if (readOnly || isParsing) {
141
+ event.preventDefault();
142
+ return;
143
+ }
144
+
145
+ activeDragRef.current = { ...section, sourceAtStart: markdownSource };
146
+ setDraggedBlockId(section.blockId);
147
+ setDropTarget(null);
148
+ event.dataTransfer.effectAllowed = 'move';
149
+ // Firefox requires a text payload before it will initiate a native drag.
150
+ // The custom type makes the payload identifiable to other Squisq panes;
151
+ // the in-memory ref remains authoritative for this mounted panel.
152
+ event.dataTransfer.setData(OUTLINE_DRAG_MIME, String(section.headingLine));
153
+ event.dataTransfer.setData('text/plain', String(section.headingLine));
154
+ },
155
+ [isParsing, markdownSource, readOnly],
156
+ );
157
+
158
+ const handleDragOver = useCallback(
159
+ (event: ReactDragEvent<HTMLElement>, section: OutlineSectionRef) => {
160
+ const dragged = activeDragRef.current;
161
+ if (!dragged) return;
162
+
163
+ // Prevent the editor shell's file-drop handler from changing this to a
164
+ // copy operation while an outline section is being moved.
165
+ event.preventDefault();
166
+ event.stopPropagation();
167
+
168
+ if (!canDropOnSection(dragged, section) || readOnly || isParsing) {
169
+ event.dataTransfer.dropEffect = 'none';
170
+ setDropTarget(null);
171
+ return;
172
+ }
173
+
174
+ const placement = placementForPointer(event);
175
+ event.dataTransfer.dropEffect = 'move';
176
+ setDropTarget((current) =>
177
+ current?.blockId === section.blockId && current.placement === placement
178
+ ? current
179
+ : { blockId: section.blockId, placement },
180
+ );
181
+ },
182
+ [isParsing, readOnly],
183
+ );
184
+
185
+ const handleDrop = useCallback(
186
+ (event: ReactDragEvent<HTMLElement>, section: OutlineSectionRef) => {
187
+ const dragged = activeDragRef.current;
188
+ if (!dragged) return;
189
+
190
+ event.preventDefault();
191
+ event.stopPropagation();
192
+ const placement = placementForPointer(event);
193
+ const canMove =
194
+ !readOnly &&
195
+ !isParsing &&
196
+ dragged.sourceAtStart === markdownSource &&
197
+ canDropOnSection(dragged, section);
198
+ const next = canMove
199
+ ? moveHeadingSectionInSource(
200
+ markdownSource,
201
+ dragged.headingLine,
202
+ section.headingLine,
203
+ placement,
204
+ )
205
+ : null;
206
+
207
+ clearDragState();
208
+ if (next != null) setMarkdownSource(next);
209
+ },
210
+ [clearDragState, isParsing, markdownSource, readOnly, setMarkdownSource],
96
211
  );
97
212
 
98
213
  // Inherit the active document theme's primary color so the current-row
@@ -137,9 +252,17 @@ export function OutlinePanel({ width, className }: OutlinePanelProps) {
137
252
  <OutlineNode
138
253
  key={b.id}
139
254
  block={b}
255
+ parentId={null}
140
256
  activeBlockId={activeBlockId}
257
+ draggedBlockId={draggedBlockId}
258
+ dropTarget={dropTarget}
259
+ mutationsDisabled={readOnly || isParsing}
141
260
  onSelect={handleSelect}
142
261
  onChangeLevel={changeHeadingLevel}
262
+ onDragStart={handleDragStart}
263
+ onDragOver={handleDragOver}
264
+ onDrop={handleDrop}
265
+ onDragEnd={clearDragState}
143
266
  />
144
267
  ))}
145
268
  </ul>
@@ -152,17 +275,34 @@ export function OutlinePanel({ width, className }: OutlinePanelProps) {
152
275
 
153
276
  function OutlineNode({
154
277
  block,
278
+ parentId,
155
279
  activeBlockId,
280
+ draggedBlockId,
281
+ dropTarget,
282
+ mutationsDisabled,
156
283
  onSelect,
157
284
  onChangeLevel,
285
+ onDragStart,
286
+ onDragOver,
287
+ onDrop,
288
+ onDragEnd,
158
289
  }: {
159
290
  block: Block;
291
+ parentId: string | null;
160
292
  activeBlockId: string | null;
293
+ draggedBlockId: string | null;
294
+ dropTarget: OutlineDropTarget | null;
295
+ mutationsDisabled: boolean;
161
296
  onSelect: (b: Block) => void;
162
297
  onChangeLevel: (block: Block, delta: number) => void;
298
+ onDragStart: (event: ReactDragEvent<HTMLElement>, section: OutlineSectionRef) => void;
299
+ onDragOver: (event: ReactDragEvent<HTMLElement>, section: OutlineSectionRef) => void;
300
+ onDrop: (event: ReactDragEvent<HTMLElement>, section: OutlineSectionRef) => void;
301
+ onDragEnd: () => void;
163
302
  }) {
164
303
  const heading = block.sourceHeading;
165
304
  const depth = heading?.depth ?? 1;
305
+ const headingLine = heading?.position?.start.line;
166
306
  const text = heading ? extractPlainText(heading).trim() : '';
167
307
  const annotation = heading?.templateAnnotation;
168
308
  const tplName = annotation?.template;
@@ -170,18 +310,52 @@ function OutlineNode({
170
310
  const isActive = block.id === activeBlockId;
171
311
  const canPromote = !!heading && depth > 1;
172
312
  const canDemote = !!heading && depth < 6;
313
+ const section =
314
+ heading && typeof headingLine === 'number'
315
+ ? { blockId: block.id, headingLine, depth, parentId }
316
+ : null;
317
+ const canDrag = section != null && !mutationsDisabled;
318
+ const isDragging = block.id === draggedBlockId;
319
+ const dropPlacement = dropTarget?.blockId === block.id ? dropTarget.placement : null;
320
+ const itemClasses = [
321
+ 'squisq-outline-item',
322
+ isDragging ? 'squisq-outline-item--dragging' : '',
323
+ dropPlacement ? `squisq-outline-item--drop-${dropPlacement}` : '',
324
+ ]
325
+ .filter(Boolean)
326
+ .join(' ');
173
327
 
174
328
  return (
175
- <li className="squisq-outline-item" role="treeitem" aria-current={isActive || undefined}>
176
- <div className="squisq-outline-row-wrap">
329
+ <li className={itemClasses} role="treeitem" aria-current={isActive || undefined}>
330
+ <div
331
+ className="squisq-outline-row-wrap"
332
+ onDragOver={section ? (event) => onDragOver(event, section) : undefined}
333
+ onDrop={section ? (event) => onDrop(event, section) : undefined}
334
+ >
177
335
  <button
178
336
  type="button"
179
337
  className={`squisq-outline-row squisq-outline-row--depth-${depth}${
180
338
  isActive ? ' squisq-outline-row--current' : ''
181
339
  }`}
182
340
  onClick={() => onSelect(block)}
341
+ draggable={canDrag}
342
+ aria-grabbed={isDragging || undefined}
343
+ onDragStart={section ? (event) => onDragStart(event, section) : undefined}
344
+ onDragEnd={onDragEnd}
183
345
  title={text || '(empty heading)'}
184
346
  >
347
+ {canDrag && (
348
+ <span className="squisq-outline-drag-handle" aria-hidden="true">
349
+ <svg width="8" height="12" viewBox="0 0 8 12">
350
+ <circle cx="2" cy="2" r="1" fill="currentColor" />
351
+ <circle cx="6" cy="2" r="1" fill="currentColor" />
352
+ <circle cx="2" cy="6" r="1" fill="currentColor" />
353
+ <circle cx="6" cy="6" r="1" fill="currentColor" />
354
+ <circle cx="2" cy="10" r="1" fill="currentColor" />
355
+ <circle cx="6" cy="10" r="1" fill="currentColor" />
356
+ </svg>
357
+ </span>
358
+ )}
185
359
  <span className="squisq-outline-row-text">{text || '(untitled)'}</span>
186
360
  {showChip && (
187
361
  <span className="squisq-outline-template-chip">{templateLabel(tplName!)}</span>
@@ -194,7 +368,7 @@ function OutlineNode({
194
368
  className="squisq-outline-row-arrow"
195
369
  aria-label={`Promote heading (currently H${depth})`}
196
370
  title="Promote heading"
197
- disabled={!canPromote}
371
+ disabled={mutationsDisabled || !canPromote}
198
372
  onClick={() => onChangeLevel(block, -1)}
199
373
  >
200
374
  <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
@@ -213,7 +387,7 @@ function OutlineNode({
213
387
  className="squisq-outline-row-arrow"
214
388
  aria-label={`Demote heading (currently H${depth})`}
215
389
  title="Demote heading"
216
- disabled={!canDemote}
390
+ disabled={mutationsDisabled || !canDemote}
217
391
  onClick={() => onChangeLevel(block, +1)}
218
392
  >
219
393
  <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
@@ -236,9 +410,17 @@ function OutlineNode({
236
410
  <OutlineNode
237
411
  key={child.id}
238
412
  block={child}
413
+ parentId={block.id}
239
414
  activeBlockId={activeBlockId}
415
+ draggedBlockId={draggedBlockId}
416
+ dropTarget={dropTarget}
417
+ mutationsDisabled={mutationsDisabled}
240
418
  onSelect={onSelect}
241
419
  onChangeLevel={onChangeLevel}
420
+ onDragStart={onDragStart}
421
+ onDragOver={onDragOver}
422
+ onDrop={onDrop}
423
+ onDragEnd={onDragEnd}
242
424
  />
243
425
  ))}
244
426
  </ul>
@@ -323,6 +505,19 @@ function useActiveOutlineBlockId(): string | null {
323
505
 
324
506
  // ── Helpers ────────────────────────────────────────────────────────
325
507
 
508
+ function canDropOnSection(dragged: OutlineSectionRef, target: OutlineSectionRef): boolean {
509
+ return (
510
+ dragged.blockId !== target.blockId &&
511
+ dragged.depth === target.depth &&
512
+ dragged.parentId === target.parentId
513
+ );
514
+ }
515
+
516
+ function placementForPointer(event: ReactDragEvent<HTMLElement>): OutlineDropPlacement {
517
+ const bounds = event.currentTarget.getBoundingClientRect();
518
+ return event.clientY < bounds.top + bounds.height / 2 ? 'before' : 'after';
519
+ }
520
+
326
521
  function hasAnyHeading(blocks: Block[]): boolean {
327
522
  for (const b of blocks) {
328
523
  if (b.sourceHeading) return true;
@@ -16,7 +16,7 @@
16
16
  * it can fetch.
17
17
  */
18
18
 
19
- import { useEffect, useMemo, useState } from 'react';
19
+ import { useEffect, useMemo, useRef, useState } from 'react';
20
20
  import type { CSSProperties } from 'react';
21
21
  import { parseMarkdown } from '@bendyline/squisq/markdown';
22
22
  import type { MarkdownDocument, HtmlNode } from '@bendyline/squisq/markdown';
@@ -68,6 +68,8 @@ export interface PlainHtmlPreviewProps {
68
68
  theme?: Theme;
69
69
  className?: string;
70
70
  style?: CSSProperties;
71
+ /** Let unmodified Up/Down arrows scroll the preview without requiring iframe focus. */
72
+ globalKeyboardShortcuts?: boolean;
71
73
  }
72
74
 
73
75
  const IFRAME_STYLE: CSSProperties = {
@@ -87,7 +89,9 @@ export function PlainHtmlPreview({
87
89
  theme,
88
90
  className,
89
91
  style,
92
+ globalKeyboardShortcuts = false,
90
93
  }: PlainHtmlPreviewProps) {
94
+ const iframeRef = useRef<HTMLIFrameElement>(null);
91
95
  const mdDoc = useMemo<MarkdownDocument>(() => parseMarkdown(markdown), [markdown]);
92
96
 
93
97
  // Resolve any relative image URLs the doc references. Blob URLs are
@@ -161,8 +165,43 @@ export function PlainHtmlPreview({
161
165
  [renderFn, mdDoc, title, mergedImages, theme, iconsCss],
162
166
  );
163
167
 
168
+ useEffect(() => {
169
+ if (!globalKeyboardShortcuts) return;
170
+ const handleKeyDown = (event: KeyboardEvent) => {
171
+ if (
172
+ event.defaultPrevented ||
173
+ event.altKey ||
174
+ event.ctrlKey ||
175
+ event.metaKey ||
176
+ event.shiftKey ||
177
+ (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
178
+ ) {
179
+ return;
180
+ }
181
+ const target = event.target instanceof Element ? event.target : null;
182
+ if (
183
+ target?.closest(
184
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"], [role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], .monaco-editor',
185
+ )
186
+ ) {
187
+ return;
188
+ }
189
+ const frameWindow = iframeRef.current?.contentWindow;
190
+ if (!frameWindow) return;
191
+ event.preventDefault();
192
+ const distance = Math.max(64, Math.round(iframeRef.current?.clientHeight ?? 0) * 0.12);
193
+ frameWindow.scrollBy({
194
+ top: event.key === 'ArrowDown' ? distance : -distance,
195
+ behavior: 'smooth',
196
+ });
197
+ };
198
+ document.addEventListener('keydown', handleKeyDown);
199
+ return () => document.removeEventListener('keydown', handleKeyDown);
200
+ }, [globalKeyboardShortcuts]);
201
+
164
202
  return (
165
203
  <iframe
204
+ ref={iframeRef}
166
205
  className={className}
167
206
  data-testid="plain-html-preview"
168
207
  title={title ?? 'HTML preview'}