@bendyline/squisq-editor-react 1.6.2 → 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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  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 +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  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 +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. package/src/diagram/useDiagramData.ts +0 -126
@@ -19,7 +19,7 @@ import { exportImageEditDoc, type ImageEditExportFormat } from '@bendyline/squis
19
19
  import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
20
20
  import { CanvasSurface } from './imageEditor/CanvasSurface.js';
21
21
  import { ImageVersionHistoryDropdown } from './imageEditor/ImageVersionHistoryDropdown.js';
22
- import { LayersPanel } from './imageEditor/LayersPanel.js';
22
+ import { LayersPanel, type AddableLayerKind } from './imageEditor/LayersPanel.js';
23
23
  import { PropertiesPanel } from './imageEditor/PropertiesPanel.js';
24
24
  import { Toolbar } from './imageEditor/Toolbar.js';
25
25
  import { useImageEditor } from './imageEditor/useImageEditor.js';
@@ -115,6 +115,7 @@ export function ImageEditor(props: ImageEditorProps) {
115
115
  const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
116
116
 
117
117
  const surfaceRef = useRef<HTMLDivElement>(null);
118
+ const imageInputRef = useRef<HTMLInputElement>(null);
118
119
  const [zoom, setZoom] = useState(1.0);
119
120
 
120
121
  const handleZoomIn = useCallback(() => {
@@ -276,6 +277,24 @@ export function ImageEditor(props: ImageEditorProps) {
276
277
  [dispatch, shapeKind],
277
278
  );
278
279
 
280
+ const handleAddLayer = useCallback(
281
+ (kind: AddableLayerKind) => {
282
+ if (!state) return;
283
+ if (kind === 'image') {
284
+ imageInputRef.current?.click();
285
+ return;
286
+ }
287
+
288
+ const { width, height } = state.doc.canvas;
289
+ if (kind === 'text') {
290
+ handleCreateTextAt(Math.max(0, (width - 240) / 2), Math.max(0, (height - 48) / 2));
291
+ return;
292
+ }
293
+ handleCreateShapeAt(width / 2, height / 2);
294
+ },
295
+ [handleCreateShapeAt, handleCreateTextAt, state],
296
+ );
297
+
279
298
  if (error) {
280
299
  return (
281
300
  <div
@@ -312,6 +331,7 @@ export function ImageEditor(props: ImageEditorProps) {
312
331
  shapeKind={state.shapeKind}
313
332
  dispatch={dispatch}
314
333
  uploadAsset={uploadAsset}
334
+ imageInputRef={imageInputRef}
315
335
  onExport={handleExport}
316
336
  onSave={saveBehavior === 'export' ? handleSaveAndClose : flush}
317
337
  saveLabel={saveLabel ?? (saveBehavior === 'export' ? 'Save and close' : 'Save')}
@@ -361,6 +381,7 @@ export function ImageEditor(props: ImageEditorProps) {
361
381
  doc={state.doc}
362
382
  selectedLayerId={state.selectedLayerId}
363
383
  dispatch={dispatch}
384
+ onAddLayer={handleAddLayer}
364
385
  />
365
386
  <PropertiesPanel
366
387
  doc={state.doc}
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * The gutter consumes the parsed `Doc` from `useEditorContext()` and reuses
10
10
  * the same template-resolution path as `LinearDocView` (heading text →
11
- * template defaults → `getLayers()` → `BlockRenderer`).
11
+ * template defaults → `materializeBlockLayers()` → `BlockRenderer`).
12
12
  *
13
13
  * Auto-hides via container queries when the surrounding container is too
14
14
  * narrow (see `.squisq-editor-with-gutter` rules in `styles/editor.css`).
@@ -31,10 +31,10 @@ import type { Block, DocBlock, ViewportConfig } from '@bendyline/squisq/schemas'
31
31
  import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
32
32
  import {
33
33
  flattenBlocks,
34
- getLayers,
35
- hasTemplate,
34
+ materializeBlockLayers,
36
35
  DEFAULT_THEME,
37
- type RenderContext,
36
+ deriveTemplateInputs,
37
+ type MaterializeBlockLayersOptions,
38
38
  } from '@bendyline/squisq/doc';
39
39
  import { extractPlainText, getChildren } from '@bendyline/squisq/markdown';
40
40
  import { usePreviewSettingsOptional } from './PreviewControls';
@@ -56,7 +56,7 @@ import { useHeadingLayout } from './useHeadingLayout';
56
56
  function isAnnotated(block: Block): boolean {
57
57
  const annotation = block.sourceHeading?.templateAnnotation;
58
58
  if (!annotation) return false;
59
- return !!annotation.template && hasTemplate(annotation.template);
59
+ return !!annotation.template;
60
60
  }
61
61
 
62
62
  function extractBodyPlainText(contents?: MarkdownBlockNode[]): string {
@@ -188,7 +188,12 @@ function getTemplateDefaults(
188
188
  ): Record<string, unknown> {
189
189
  switch (templateName) {
190
190
  case 'statHighlight':
191
- return { stat: headingText, description: bodyText || headingText };
191
+ return (
192
+ deriveTemplateInputs(templateName, headingText, contents, { placeholders: true }) ?? {
193
+ stat: headingText,
194
+ description: bodyText || headingText,
195
+ }
196
+ );
192
197
  case 'quote':
193
198
  case 'fullBleedQuote':
194
199
  case 'pullQuote':
@@ -334,6 +339,8 @@ export function InlinePreviewGutter({
334
339
  duration: 1,
335
340
  audioSegment: 0,
336
341
  title: headingText,
342
+ contents: block.contents,
343
+ children: block.children,
337
344
  ...getTemplateDefaults(
338
345
  template,
339
346
  headingText,
@@ -345,15 +352,16 @@ export function InlinePreviewGutter({
345
352
  ...block.templateOverrides,
346
353
  };
347
354
 
348
- const ctx: RenderContext = {
355
+ const ctx: MaterializeBlockLayersOptions = {
349
356
  blockIndex: index,
350
357
  totalBlocks,
351
358
  theme: activeTheme,
352
359
  viewport,
360
+ customTemplates: doc.customTemplates,
353
361
  };
354
362
 
355
363
  try {
356
- const layers = getLayers(templateBlock as unknown as DocBlock, ctx);
364
+ const { layers } = materializeBlockLayers(templateBlock as unknown as DocBlock, ctx);
357
365
  const visualBlock = {
358
366
  ...block,
359
367
  layers,
package/src/MediaBin.tsx CHANGED
@@ -9,6 +9,7 @@
9
9
  import { useState, useEffect, useRef, useCallback } from 'react';
10
10
  import type { MediaProvider, MediaEntry } from '@bendyline/squisq/schemas';
11
11
  import { SQUISQ_MEDIA_MIME } from './mediaDragMime';
12
+ import { filterVisibleMediaEntries } from './mediaEntries';
12
13
 
13
14
  // ============================================
14
15
  // Types
@@ -24,8 +25,8 @@ export interface MediaBinProps {
24
25
  /** Relative media paths currently referenced by the document. */
25
26
  usedMediaPaths?: ReadonlySet<string>;
26
27
  /**
27
- * Fired after a successful upload via the MediaBin's own "+ Upload"
28
- * button. `relativePath` is what the provider returned (the same
28
+ * Fired after a successful upload via the MediaBin's "+ Upload"
29
+ * button or image drop target. `relativePath` is what the provider returned (the same
29
30
  * value embedded in markdown refs, e.g. `attachments/xyz.png`);
30
31
  * `name` is the uploader-chosen filename before storage renamed
31
32
  * it. Consumers typically use this to insert a markdown image ref
@@ -66,6 +67,24 @@ function isImageMime(mimeType: string): boolean {
66
67
  return mimeType.startsWith('image/');
67
68
  }
68
69
 
70
+ const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'ico']);
71
+
72
+ function isImageFile(file: Pick<File, 'name' | 'type'>): boolean {
73
+ if (isImageMime(file.type)) return true;
74
+ const dot = file.name.lastIndexOf('.');
75
+ return dot >= 0 && IMAGE_EXTENSIONS.has(file.name.slice(dot + 1).toLowerCase());
76
+ }
77
+
78
+ /** During drag-over browsers expose item MIME types but often not filenames. */
79
+ function dataTransferMayContainImage(dataTransfer: DataTransfer): boolean {
80
+ if (dataTransfer.items.length > 0) {
81
+ return Array.from(dataTransfer.items).some(
82
+ (item) => item.kind === 'file' && (item.type === '' || isImageMime(item.type)),
83
+ );
84
+ }
85
+ return Array.from(dataTransfer.files).some(isImageFile);
86
+ }
87
+
69
88
  function sortMediaEntries(entries: MediaEntry[]): MediaEntry[] {
70
89
  return entries.sort((a, b) => {
71
90
  const aImg = isImageMime(a.mimeType) ? 0 : 1;
@@ -91,6 +110,7 @@ export function MediaBin({
91
110
  const [entries, setEntries] = useState<MediaEntry[]>([]);
92
111
  const [thumbUrls, setThumbUrls] = useState<Record<string, string>>({});
93
112
  const [loading, setLoading] = useState(false);
113
+ const [isDropActive, setIsDropActive] = useState(false);
94
114
  const [contextMenu, setContextMenu] = useState<{
95
115
  entry: MediaEntry;
96
116
  x: number;
@@ -98,10 +118,11 @@ export function MediaBin({
98
118
  } | null>(null);
99
119
  const fileInputRef = useRef<HTMLInputElement>(null);
100
120
  const contextMenuRef = useRef<HTMLDivElement>(null);
121
+ const dropDepthRef = useRef(0);
101
122
 
102
123
  const updateEntries = useCallback(
103
124
  async (provider: MediaProvider) => {
104
- const list = sortMediaEntries(await provider.listMedia());
125
+ const list = sortMediaEntries(filterVisibleMediaEntries(await provider.listMedia()));
105
126
  setEntries(list);
106
127
  onCountChange?.(list.length);
107
128
 
@@ -159,7 +180,7 @@ export function MediaBin({
159
180
  async function scan() {
160
181
  setLoading(true);
161
182
  try {
162
- const list = await mediaProvider!.listMedia();
183
+ const list = filterVisibleMediaEntries(await mediaProvider!.listMedia());
163
184
  if (cancelled) return;
164
185
 
165
186
  sortMediaEntries(list);
@@ -194,16 +215,12 @@ export function MediaBin({
194
215
  fileInputRef.current?.click();
195
216
  }, []);
196
217
 
197
- const handleFileChange = useCallback(
198
- async (e: React.ChangeEvent<HTMLInputElement>) => {
199
- const files = e.target.files;
200
- if (!files || !mediaProvider) return;
201
-
218
+ const uploadFiles = useCallback(
219
+ async (files: readonly File[]) => {
220
+ if (files.length === 0 || !mediaProvider) return;
202
221
  setLoading(true);
203
222
  try {
204
- for (let i = 0; i < files.length; i++) {
205
- const file = files[i];
206
- if (!file) continue;
223
+ for (const file of files) {
207
224
  const buffer = await file.arrayBuffer();
208
225
  const mimeType = file.type || 'application/octet-stream';
209
226
  const relativePath = await mediaProvider.addMedia(file.name, buffer, mimeType);
@@ -219,12 +236,67 @@ export function MediaBin({
219
236
  await updateEntries(mediaProvider);
220
237
  } finally {
221
238
  setLoading(false);
222
- if (fileInputRef.current) fileInputRef.current.value = '';
223
239
  }
224
240
  },
225
241
  [mediaProvider, onMediaUploaded, updateEntries],
226
242
  );
227
243
 
244
+ const handleFileChange = useCallback(
245
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
246
+ try {
247
+ await uploadFiles(Array.from(e.target.files ?? []));
248
+ } finally {
249
+ if (fileInputRef.current) fileInputRef.current.value = '';
250
+ }
251
+ },
252
+ [uploadFiles],
253
+ );
254
+
255
+ const handleDragEnter = useCallback(
256
+ (event: React.DragEvent<HTMLDivElement>) => {
257
+ if (!mediaProvider || loading || !dataTransferMayContainImage(event.dataTransfer)) return;
258
+ event.preventDefault();
259
+ event.stopPropagation();
260
+ dropDepthRef.current += 1;
261
+ setIsDropActive(true);
262
+ },
263
+ [loading, mediaProvider],
264
+ );
265
+
266
+ const handleDragOver = useCallback(
267
+ (event: React.DragEvent<HTMLDivElement>) => {
268
+ if (!mediaProvider || loading || !dataTransferMayContainImage(event.dataTransfer)) return;
269
+ event.preventDefault();
270
+ event.stopPropagation();
271
+ event.dataTransfer.dropEffect = 'copy';
272
+ },
273
+ [loading, mediaProvider],
274
+ );
275
+
276
+ const handleDragLeave = useCallback(
277
+ (event: React.DragEvent<HTMLDivElement>) => {
278
+ if (!isDropActive) return;
279
+ event.preventDefault();
280
+ event.stopPropagation();
281
+ dropDepthRef.current = Math.max(0, dropDepthRef.current - 1);
282
+ if (dropDepthRef.current === 0) setIsDropActive(false);
283
+ },
284
+ [isDropActive],
285
+ );
286
+
287
+ const handleDrop = useCallback(
288
+ (event: React.DragEvent<HTMLDivElement>) => {
289
+ const imageFiles = Array.from(event.dataTransfer.files).filter(isImageFile);
290
+ if (!mediaProvider || (!isDropActive && imageFiles.length === 0)) return;
291
+ event.preventDefault();
292
+ event.stopPropagation();
293
+ dropDepthRef.current = 0;
294
+ setIsDropActive(false);
295
+ if (imageFiles.length > 0) void uploadFiles(imageFiles);
296
+ },
297
+ [isDropActive, mediaProvider, uploadFiles],
298
+ );
299
+
228
300
  const handleRemoveEntry = useCallback(
229
301
  async (entry: MediaEntry) => {
230
302
  if (!mediaProvider) return;
@@ -279,7 +351,20 @@ export function MediaBin({
279
351
  );
280
352
 
281
353
  return (
282
- <div className={`squisq-media-bin${isDark ? ' squisq-media-bin--dark' : ''}`}>
354
+ <div
355
+ className={[
356
+ 'squisq-media-bin',
357
+ isDark ? 'squisq-media-bin--dark' : '',
358
+ isDropActive ? 'squisq-media-bin--drop-active' : '',
359
+ ]
360
+ .filter(Boolean)
361
+ .join(' ')}
362
+ onDragEnter={handleDragEnter}
363
+ onDragOver={handleDragOver}
364
+ onDragLeave={handleDragLeave}
365
+ onDrop={handleDrop}
366
+ aria-busy={loading}
367
+ >
283
368
  {/* Header */}
284
369
  <div className="squisq-media-bin-header">
285
370
  <span className="squisq-media-bin-title">
@@ -374,6 +459,14 @@ export function MediaBin({
374
459
  })}
375
460
  </div>
376
461
 
462
+ {isDropActive && (
463
+ <div className="squisq-media-bin-drop-target" aria-hidden="true">
464
+ <span className="squisq-media-bin-drop-target-icon">+</span>
465
+ <strong>Drop images here</strong>
466
+ <span>Add to Files</span>
467
+ </div>
468
+ )}
469
+
377
470
  {contextMenu && (
378
471
  <div
379
472
  ref={contextMenuRef}
@@ -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;