@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
@@ -16,6 +16,7 @@ import {
16
16
  type DocumentLinkProvider,
17
17
  type ViewPreferences,
18
18
  type ThemeInheritance,
19
+ type BlockTagVisibility,
19
20
  } from './EditorContext';
20
21
  import { Toolbar } from './Toolbar';
21
22
  import { StatusBar } from './StatusBar';
@@ -34,10 +35,9 @@ import { ImageEditor } from './ImageEditor';
34
35
  import {
35
36
  PreviewSettingsProvider,
36
37
  PreviewToolbarControls,
37
- PreviewModeSwitch,
38
- PreviewFormatSwitch,
39
38
  ThemeDesignerDock,
40
39
  } from './PreviewControls';
40
+ import { PresentationModeControl, PresentationModeProvider } from './presentation/PresentationMode';
41
41
  import { CustomThemeProvider, useDocCustomThemes } from './customThemes';
42
42
  import { MediaBin } from './MediaBin';
43
43
  import { DropZoneOverlay } from './DropZoneOverlay';
@@ -49,6 +49,11 @@ import {
49
49
  processTextFile,
50
50
  processTextFiles,
51
51
  } from './utils/dropUtils';
52
+ import {
53
+ collectMediaReferencesFromMarkdown,
54
+ removeMediaReferencesFromMarkdown,
55
+ } from './mediaReferences';
56
+ import { filterVisibleMediaEntries } from './mediaEntries';
52
57
  import type { MediaProvider, Theme } from '@bendyline/squisq/schemas';
53
58
  import { DARK_SURFACE, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
54
59
  import type { ContentContainer } from '@bendyline/squisq/storage';
@@ -58,7 +63,8 @@ import {
58
63
  createMediaProviderFromContainer,
59
64
  } from '@bendyline/squisq/storage';
60
65
  import type { PrunePolicy, SaveVersionResult } from '@bendyline/squisq/versions';
61
- import type { CSSProperties, ReactNode } from 'react';
66
+ import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
67
+ import { MediaContext } from '@bendyline/squisq-react';
62
68
 
63
69
  export type { EditorColorScheme } from './EditorContext';
64
70
 
@@ -112,12 +118,6 @@ export interface EditorShellProps {
112
118
  * this container via `createMediaProviderFromContainer`.
113
119
  */
114
120
  workspaceContainer?: ContentContainer | null;
115
- /**
116
- * @deprecated Renamed to `workspaceContainer` to make the workspace-
117
- * vs. doc-scoped distinction explicit. Still accepted as a fallback
118
- * for now; remove in the next breaking release.
119
- */
120
- container?: ContentContainer | null;
121
121
  /**
122
122
  * Enable version history. Snapshots are stored at
123
123
  * `.versions/<basename>.<timestamp>.md` inside the same
@@ -269,6 +269,14 @@ export interface EditorShellProps {
269
269
  * the button is hidden regardless of this prop.
270
270
  */
271
271
  allowRecording?: boolean;
272
+ /**
273
+ * Whether the Narrate (teleprompter) display mode is offered under the
274
+ * Use tab. Defaults to true. Orthogonal to `allowRecording` — the
275
+ * prompter is useful without capture (reading for external recording
276
+ * software), and the in-mode Record affordance additionally requires
277
+ * `allowRecording` + a `mediaProvider`.
278
+ */
279
+ allowNarrate?: boolean;
272
280
  /**
273
281
  * Placeholder text shown in the WYSIWYG editor while the document is
274
282
  * empty. When omitted, the editor rotates through its own generic
@@ -348,12 +356,16 @@ export interface EditorShellProps {
348
356
  */
349
357
  outlineWidth?: number;
350
358
  /**
351
- * Initial visibility of inline block-template tags on headings — the
352
- * chip rendered next to each heading in the WYSIWYG view that opens
353
- * the block-template picker. Defaults to true; the View menu can
354
- * toggle it at runtime regardless of the initial value.
359
+ * Legacy initial visibility of inline block-template tags on headings.
360
+ * `true` maps to always visible and `false` maps to hidden. When omitted,
361
+ * {@link blockTagVisibility} defaults to `'active'`.
355
362
  */
356
363
  blockTags?: boolean;
364
+ /**
365
+ * Initial block-tag visibility mode. Takes precedence over `blockTags`.
366
+ * Defaults to `'active'` (selected/hovered block only).
367
+ */
368
+ blockTagVisibility?: BlockTagVisibility;
357
369
  /**
358
370
  * How much of the active Squisq theme the WYSIWYG editing surface
359
371
  * mirrors. Defaults to `'fonts'` — the historical behavior of
@@ -365,7 +377,8 @@ export interface EditorShellProps {
365
377
  * Bundled view preferences — a serializable JSON blob covering the
366
378
  * runtime-toggleable view options surfaced in the View menu. When
367
379
  * provided, fields here override the corresponding individual props
368
- * (`outline`, `inlinePreview`, `showStatusBar`). Pair with
380
+ * (`outline`, `inlinePreview`, `showStatusBar`, `blockTagVisibility`,
381
+ * `blockTags`). Pair with
369
382
  * {@link onViewPreferencesChange} to externalize storage of these
370
383
  * preferences in the host.
371
384
  */
@@ -403,7 +416,6 @@ export function EditorShell({
403
416
  maxHeight,
404
417
  mediaProvider,
405
418
  workspaceContainer,
406
- container,
407
419
  allowVersioning = false,
408
420
  versionBasename,
409
421
  versioningPrunePolicy,
@@ -426,6 +438,7 @@ export function EditorShell({
426
438
  mentionProvider,
427
439
  documentLinkProvider,
428
440
  allowRecording = true,
441
+ allowNarrate = true,
429
442
  placeholder,
430
443
  readOnly = false,
431
444
  imageSrc,
@@ -437,13 +450,14 @@ export function EditorShell({
437
450
  inlinePreviewWidth = 320,
438
451
  outline = false,
439
452
  outlineWidth,
440
- blockTags = true,
453
+ blockTags,
454
+ blockTagVisibility,
441
455
  themeInheritance = 'fonts',
442
456
  viewPreferences,
443
457
  onViewPreferencesChange,
444
458
  themeOverride = null,
445
459
  }: EditorShellProps) {
446
- const effectiveContainer = workspaceContainer ?? container ?? null;
460
+ const effectiveContainer = workspaceContainer ?? null;
447
461
 
448
462
  // If the host gave us a `workspaceContainer` but no explicit `mediaProvider`,
449
463
  // derive one automatically. Without this, drag-and-drop of an image
@@ -464,65 +478,69 @@ export function EditorShell({
464
478
  !showPlayTab && initialView === 'preview' ? 'wysiwyg' : initialView;
465
479
 
466
480
  return (
467
- <EditorProvider
468
- initialMarkdown={initialMarkdown}
469
- initialView={effectiveInitialView}
470
- articleId={articleId}
471
- colorScheme={colorScheme}
472
- workspaceContainer={effectiveContainer}
473
- allowVersioning={allowVersioning}
474
- versionBasename={versionBasename}
475
- versioningPrunePolicy={versioningPrunePolicy}
476
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
477
- onSaveVersion={onSaveVersion}
478
- mediaProvider={effectiveMediaProvider}
479
- imageDisplayMode={imageDisplayMode}
480
- mentionProvider={mentionProvider}
481
- documentLinkProvider={documentLinkProvider}
482
- allowRecording={allowRecording}
483
- fileName={fileName}
484
- language={language}
485
- inlinePreview={inlinePreview}
486
- showStatusBar={showStatusBar}
487
- outline={outline}
488
- blockTags={blockTags}
489
- themeInheritance={themeInheritance}
490
- viewPreferences={viewPreferences}
491
- onViewPreferencesChange={onViewPreferencesChange}
492
- >
493
- <EditorShellInner
494
- basePath={basePath}
495
- onChange={onChange}
496
- className={className}
497
- height={height}
498
- minHeight={minHeight}
499
- maxHeight={maxHeight}
500
- placeholder={placeholder}
501
- mediaProvider={effectiveMediaProvider ?? null}
481
+ <MediaContext.Provider value={effectiveMediaProvider ?? null}>
482
+ <EditorProvider
483
+ initialMarkdown={initialMarkdown}
484
+ initialView={effectiveInitialView}
485
+ articleId={articleId}
486
+ colorScheme={colorScheme}
502
487
  workspaceContainer={effectiveContainer}
503
- filesToggleEnabled={filesToggleEnabled}
504
- toolbarSlotLeft={toolbarSlotLeft}
505
- toolbarSlotAfterActions={toolbarSlotAfterActions}
506
- toolbarSlotRight={toolbarSlotRight}
507
- showPlayTab={showPlayTab}
508
- submitOnEnter={submitOnEnter}
509
- codeContext={codeContext}
510
- fullWidth={fullWidth}
511
- uxFont={uxFont}
512
- thinMargins={thinMargins}
513
- readOnly={readOnly}
514
- imageSrc={imageSrc}
515
- imageAlt={imageAlt}
516
- imageMode={imageMode}
517
- imageEditorContainer={imageEditorContainer}
518
- onImageExport={onImageExport}
519
488
  allowVersioning={allowVersioning}
489
+ versionBasename={versionBasename}
490
+ versioningPrunePolicy={versioningPrunePolicy}
520
491
  versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
521
- inlinePreviewWidth={inlinePreviewWidth}
522
- outlineWidth={outlineWidth}
523
- themeOverride={themeOverride}
524
- />
525
- </EditorProvider>
492
+ onSaveVersion={onSaveVersion}
493
+ mediaProvider={effectiveMediaProvider}
494
+ imageDisplayMode={imageDisplayMode}
495
+ mentionProvider={mentionProvider}
496
+ documentLinkProvider={documentLinkProvider}
497
+ allowRecording={allowRecording}
498
+ allowNarrate={allowNarrate}
499
+ fileName={fileName}
500
+ language={language}
501
+ inlinePreview={inlinePreview}
502
+ showStatusBar={showStatusBar}
503
+ outline={outline}
504
+ blockTags={blockTags}
505
+ blockTagVisibility={blockTagVisibility}
506
+ themeInheritance={themeInheritance}
507
+ viewPreferences={viewPreferences}
508
+ onViewPreferencesChange={onViewPreferencesChange}
509
+ >
510
+ <EditorShellInner
511
+ basePath={basePath}
512
+ onChange={onChange}
513
+ className={className}
514
+ height={height}
515
+ minHeight={minHeight}
516
+ maxHeight={maxHeight}
517
+ placeholder={placeholder}
518
+ mediaProvider={effectiveMediaProvider ?? null}
519
+ workspaceContainer={effectiveContainer}
520
+ filesToggleEnabled={filesToggleEnabled}
521
+ toolbarSlotLeft={toolbarSlotLeft}
522
+ toolbarSlotAfterActions={toolbarSlotAfterActions}
523
+ toolbarSlotRight={toolbarSlotRight}
524
+ showPlayTab={showPlayTab}
525
+ submitOnEnter={submitOnEnter}
526
+ codeContext={codeContext}
527
+ fullWidth={fullWidth}
528
+ uxFont={uxFont}
529
+ thinMargins={thinMargins}
530
+ readOnly={readOnly}
531
+ imageSrc={imageSrc}
532
+ imageAlt={imageAlt}
533
+ imageMode={imageMode}
534
+ imageEditorContainer={imageEditorContainer}
535
+ onImageExport={onImageExport}
536
+ allowVersioning={allowVersioning}
537
+ versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
538
+ inlinePreviewWidth={inlinePreviewWidth}
539
+ outlineWidth={outlineWidth}
540
+ themeOverride={themeOverride}
541
+ />
542
+ </EditorProvider>
543
+ </MediaContext.Provider>
526
544
  );
527
545
  }
528
546
 
@@ -591,6 +609,7 @@ function EditorShellInner({
591
609
  outlineWidth,
592
610
  themeOverride,
593
611
  }: EditorShellInnerProps) {
612
+ const shellRef = useRef<HTMLDivElement>(null);
594
613
  const {
595
614
  activeView,
596
615
  markdownSource,
@@ -614,6 +633,7 @@ function EditorShellInner({
614
633
  imageEditTarget,
615
634
  closeImageEdit,
616
635
  bumpMediaRevision,
636
+ mediaRevision,
617
637
  } = useEditorContext();
618
638
  // Dual-catalog custom themes (doc + browser library), mirroring how
619
639
  // CustomTemplateProvider is fed. Wraps the preview subtree so the theme
@@ -632,6 +652,12 @@ function EditorShellInner({
632
652
  const isTimelineMode = isMarkdownMode && layoutMode === 'timeline' && !isPreview;
633
653
  const [showFiles, setShowFiles] = useState(false);
634
654
  const [mediaRefreshKey, setMediaRefreshKey] = useState(0);
655
+ const [mediaCount, setMediaCount] = useState(0);
656
+ const mediaListRefreshKey = mediaRefreshKey + mediaRevision;
657
+ const usedMediaPaths = useMemo(
658
+ () => collectMediaReferencesFromMarkdown(markdownSource),
659
+ [markdownSource],
660
+ );
635
661
  // Persistent fallback container for image-edit sidecars when the host
636
662
  // didn't supply one. Lifted to shell scope so opening the same image
637
663
  // multiple times sees the same `.imageEdits/<sanitized>/.versions/...`
@@ -644,6 +670,33 @@ function EditorShellInner({
644
670
  const imageEditFallbackContainer = imageEditFallbackContainerRef.current;
645
671
  const isDark = colorScheme === 'dark';
646
672
 
673
+ useEffect(() => {
674
+ if (!mediaProvider) {
675
+ setMediaCount(0);
676
+ return;
677
+ }
678
+
679
+ let cancelled = false;
680
+ mediaProvider.listMedia().then(
681
+ (entries) => {
682
+ if (!cancelled) setMediaCount(filterVisibleMediaEntries(entries).length);
683
+ },
684
+ (err: unknown) => {
685
+ if (!cancelled) {
686
+ setMediaCount(0);
687
+ console.warn(
688
+ '[squisq-editor] Failed to read media list:',
689
+ err instanceof Error ? err.message : err,
690
+ );
691
+ }
692
+ },
693
+ );
694
+
695
+ return () => {
696
+ cancelled = true;
697
+ };
698
+ }, [mediaProvider, mediaListRefreshKey]);
699
+
647
700
  const handleToggleFiles = useCallback(() => {
648
701
  setShowFiles((prev) => !prev);
649
702
  }, []);
@@ -701,6 +754,24 @@ function EditorShellInner({
701
754
  [activeView, tiptapEditor, monacoEditor, insertAtCursor, markdownSource, setMarkdownSource],
702
755
  );
703
756
 
757
+ const handleMediaUploaded = useCallback(
758
+ (relativePath: string, name: string, mimeType: string) => {
759
+ insertMediaRef(relativePath, name, mimeType);
760
+ setMediaRefreshKey((k) => k + 1);
761
+ },
762
+ [insertMediaRef],
763
+ );
764
+
765
+ const handleMediaRemoved = useCallback(
766
+ (relativePath: string) => {
767
+ const nextSource = removeMediaReferencesFromMarkdown(markdownSource, relativePath);
768
+ if (nextSource !== markdownSource) replaceAll(nextSource);
769
+ setMediaRefreshKey((k) => k + 1);
770
+ bumpMediaRevision();
771
+ },
772
+ [bumpMediaRevision, markdownSource, replaceAll],
773
+ );
774
+
704
775
  const handleFileDrop = useCallback(
705
776
  async (files: File[], target: DropTarget) => {
706
777
  try {
@@ -746,6 +817,7 @@ function EditorShellInner({
746
817
 
747
818
  const { isDragging, dragContentType, containerProps, zoneProps } = useFileDrop({
748
819
  onDrop: handleFileDrop,
820
+ enabled: !readOnly,
749
821
  });
750
822
 
751
823
  // Notify parent of changes
@@ -753,35 +825,28 @@ function EditorShellInner({
753
825
  onChange?.(markdownSource);
754
826
  }, [markdownSource, onChange]);
755
827
 
756
- // Keyboard shortcuts for view switching
757
- useEffect(() => {
758
- const handler = (e: KeyboardEvent) => {
759
- if (e.ctrlKey || e.metaKey) {
760
- switch (e.key) {
761
- case '1':
762
- e.preventDefault();
763
- document.querySelector<HTMLButtonElement>('[data-view="wysiwyg"]')?.click();
764
- break;
765
- case '2':
766
- e.preventDefault();
767
- document.querySelector<HTMLButtonElement>('[data-view="raw"]')?.click();
768
- break;
769
- case '3':
770
- if (!showPlayTab) return;
771
- e.preventDefault();
772
- document.querySelector<HTMLButtonElement>('[data-view="preview"]')?.click();
773
- break;
774
- }
775
- }
776
- };
777
- window.addEventListener('keydown', handler);
778
- return () => window.removeEventListener('keydown', handler);
779
- }, [showPlayTab]);
828
+ // View shortcuts bubble only through the editor that currently owns focus.
829
+ const handleShellKeyDown = useCallback(
830
+ (e: ReactKeyboardEvent<HTMLDivElement>) => {
831
+ if (!e.ctrlKey && !e.metaKey) return;
832
+ const view =
833
+ e.key === '1' ? 'wysiwyg' : e.key === '2' ? 'raw' : e.key === '3' ? 'preview' : null;
834
+ if (!view || (view === 'preview' && !showPlayTab)) return;
835
+ const button = shellRef.current?.querySelector<HTMLButtonElement>(`[data-view="${view}"]`);
836
+ if (!button) return;
837
+ e.preventDefault();
838
+ e.stopPropagation();
839
+ button.click();
840
+ },
841
+ [showPlayTab],
842
+ );
780
843
 
781
844
  const autoGrow = minHeight !== undefined || maxHeight !== undefined;
782
845
 
783
846
  return (
784
847
  <div
848
+ ref={shellRef}
849
+ onKeyDown={handleShellKeyDown}
785
850
  className={`squisq-editor-shell ${className || ''}`}
786
851
  data-theme={colorScheme}
787
852
  data-full-width={fullWidth ? 'true' : undefined}
@@ -812,97 +877,103 @@ function EditorShellInner({
812
877
  >
813
878
  <CustomThemeProvider docThemes={docThemes} onDocThemesChange={onDocThemesChange}>
814
879
  <PreviewSettingsProvider doc={doc} themeOverride={themeOverride}>
815
- {/* Header. In image mode the full markdown/code Toolbar is replaced
880
+ <PresentationModeProvider rootRef={shellRef}>
881
+ {/* Header. In image mode the full markdown/code Toolbar is replaced
816
882
  with a minimal slot bar — view tabs, formatting, and preview
817
883
  controls don't apply to a binary asset. */}
818
- {isImageMode ? (
819
- (toolbarSlotLeft || toolbarSlotRight) && (
820
- <div className="squisq-editor-header squisq-editor-header--image">
821
- {toolbarSlotLeft}
822
- <div style={{ flex: 1 }} />
823
- {toolbarSlotRight}
884
+ {isImageMode ? (
885
+ (toolbarSlotLeft || toolbarSlotRight) && (
886
+ <div className="squisq-editor-header squisq-editor-header--image">
887
+ {toolbarSlotLeft}
888
+ <div style={{ flex: 1 }} />
889
+ {toolbarSlotRight}
890
+ </div>
891
+ )
892
+ ) : (
893
+ <div className="squisq-editor-header">
894
+ <Toolbar
895
+ showFiles={showFiles}
896
+ fileCount={mediaCount}
897
+ onToggleFiles={!isCodeMode && filesToggleEnabled ? handleToggleFiles : undefined}
898
+ slotLeft={toolbarSlotLeft}
899
+ slotAfterTabs={
900
+ !isCodeMode &&
901
+ isPreview && (
902
+ <>
903
+ <PreviewToolbarControls />
904
+ <PresentationModeControl />
905
+ </>
906
+ )
907
+ }
908
+ slotAfterActions={toolbarSlotAfterActions}
909
+ slotRight={toolbarSlotRight}
910
+ showPlayTab={showPlayTab}
911
+ />
824
912
  </div>
825
- )
826
- ) : (
827
- <div className="squisq-editor-header">
828
- <Toolbar
829
- showFiles={showFiles}
830
- onToggleFiles={!isCodeMode && filesToggleEnabled ? handleToggleFiles : undefined}
831
- slotLeft={toolbarSlotLeft}
832
- slotAfterTabs={
833
- !isCodeMode &&
834
- isPreview && (
835
- <div className="squisq-preview-left-controls">
836
- <PreviewModeSwitch />
837
- <span className="squisq-preview-seg-divider" aria-hidden="true" />
838
- <PreviewFormatSwitch />
839
- </div>
840
- )
841
- }
842
- slotAfterActions={
843
- <>
844
- {toolbarSlotAfterActions}
845
- {!isCodeMode && isPreview && <PreviewToolbarControls />}
846
- </>
847
- }
848
- slotRight={toolbarSlotRight}
849
- showPlayTab={showPlayTab}
850
- />
851
- </div>
852
- )}
913
+ )}
853
914
 
854
- {/* Main content area */}
855
- <div
856
- className="squisq-editor-content"
857
- style={{
858
- flex: autoGrow ? '1 1 auto' : 1,
859
- overflowY: autoGrow ? 'auto' : 'hidden',
860
- overflowX: 'hidden',
861
- minHeight: 0,
862
- position: 'relative',
863
- display: 'flex',
864
- }}
865
- >
915
+ {/* Main content area */}
866
916
  <div
917
+ className="squisq-editor-content"
867
918
  style={{
868
919
  flex: autoGrow ? '1 1 auto' : 1,
869
- overflow: autoGrow ? 'visible' : 'hidden',
920
+ overflowY: autoGrow ? 'auto' : 'hidden',
921
+ overflowX: 'hidden',
870
922
  minHeight: 0,
871
923
  position: 'relative',
924
+ display: 'flex',
872
925
  }}
873
926
  >
874
- {isImageMode &&
875
- imageSrc &&
876
- (imageMode === 'edit' && imageEditorContainer ? (
877
- <ImageEditor
878
- filesContainer={imageEditorContainer}
879
- initialSrc={imageSrc}
880
- allowVersioning={allowVersioning}
881
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
882
- onExport={onImageExport}
883
- />
884
- ) : (
885
- <ImageViewer src={imageSrc} alt={imageAlt} theme={colorScheme} />
886
- ))}
887
- {/* Raw (Monaco) view. Always wrapped in `.squisq-editor-with-gutter`
927
+ <div
928
+ style={{
929
+ flex: autoGrow ? '1 1 auto' : 1,
930
+ overflow: autoGrow ? 'visible' : 'hidden',
931
+ minHeight: 0,
932
+ position: 'relative',
933
+ }}
934
+ >
935
+ {isImageMode &&
936
+ imageSrc &&
937
+ (imageMode === 'edit' && imageEditorContainer ? (
938
+ <ImageEditor
939
+ filesContainer={imageEditorContainer}
940
+ initialSrc={imageSrc}
941
+ allowVersioning={allowVersioning}
942
+ versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
943
+ onExport={onImageExport}
944
+ surface={colorScheme === 'dark' ? DARK_SURFACE : LIGHT_SURFACE}
945
+ />
946
+ ) : (
947
+ <ImageViewer src={imageSrc} alt={imageAlt} theme={colorScheme} />
948
+ ))}
949
+ {/* Raw (Monaco) view. Always wrapped in `.squisq-editor-with-gutter`
888
950
  so toggling a pane on/off doesn't change the editor's tree
889
951
  position — Monaco stays mounted and `monacoEditor` in
890
952
  context stays stable, which is what `useHeadingLayout` needs
891
953
  to compute positions. */}
892
- {!isImageMode && activeView === 'raw' && (
893
- <div className="squisq-editor-with-gutter" key="raw-shell">
894
- {isMarkdownMode && outlineVisible && (
895
- <OutlinePanel key="outline" width={outlineWidth} />
896
- )}
897
- {isCardMode ? (
898
- <BlockCardView
899
- key="raw-card"
900
- blockCount={blockCount}
901
- activeBlockKey={activeBlockKey}
902
- onPrev={prevBlock}
903
- onNext={nextBlock}
904
- onAdd={addBlock}
905
- >
954
+ {!isImageMode && activeView === 'raw' && (
955
+ <div className="squisq-editor-with-gutter" key="raw-shell">
956
+ {isMarkdownMode && outlineVisible && (
957
+ <OutlinePanel key="outline" width={outlineWidth} readOnly={readOnly} />
958
+ )}
959
+ {isCardMode ? (
960
+ <BlockCardView
961
+ key="raw-card"
962
+ blockCount={blockCount}
963
+ activeBlockKey={activeBlockKey}
964
+ onPrev={prevBlock}
965
+ onNext={nextBlock}
966
+ onAdd={addBlock}
967
+ >
968
+ <div key="raw-editor" className="squisq-raw-editor-container">
969
+ <RawEditor
970
+ monacoTheme={colorScheme === 'dark' ? 'vs-dark' : 'vs'}
971
+ submitOnEnter={submitOnEnter}
972
+ readOnly={readOnly}
973
+ />
974
+ </div>
975
+ </BlockCardView>
976
+ ) : (
906
977
  <div key="raw-editor" className="squisq-raw-editor-container">
907
978
  <RawEditor
908
979
  monacoTheme={colorScheme === 'dark' ? 'vs-dark' : 'vs'}
@@ -910,100 +981,96 @@ function EditorShellInner({
910
981
  readOnly={readOnly}
911
982
  />
912
983
  </div>
913
- </BlockCardView>
914
- ) : (
915
- <div key="raw-editor" className="squisq-raw-editor-container">
916
- <RawEditor
917
- monacoTheme={colorScheme === 'dark' ? 'vs-dark' : 'vs'}
918
- submitOnEnter={submitOnEnter}
919
- readOnly={readOnly}
920
- />
921
- </div>
922
- )}
923
- {/* Renders nothing in normal flow — portals context
984
+ )}
985
+ {/* Renders nothing in normal flow — portals context
924
986
  sections into Monaco view zones via the context's
925
987
  monacoEditor. Code mode only. */}
926
- {isCodeMode && codeContext && (
927
- <CodeContextZones key="code-context" options={codeContext} />
928
- )}
929
- {isMarkdownMode && isCardMode && inlinePreviewVisible && (
930
- <BlockPreviewPanel key="block-preview" basePath={basePath} />
931
- )}
932
- {isMarkdownMode && !isCardMode && inlinePreviewVisible && (
933
- <InlinePreviewGutter
934
- key="inline"
935
- width={inlinePreviewWidth}
936
- basePath={basePath}
937
- mediaProvider={mediaProvider}
938
- />
939
- )}
940
- </div>
941
- )}
942
- {/* WYSIWYG + Preview are markdown-only surfaces — skip them
988
+ {isCodeMode && codeContext && (
989
+ <CodeContextZones key="code-context" options={codeContext} />
990
+ )}
991
+ {isMarkdownMode && isCardMode && inlinePreviewVisible && (
992
+ <BlockPreviewPanel key="block-preview" basePath={basePath} />
993
+ )}
994
+ {isMarkdownMode && !isCardMode && inlinePreviewVisible && (
995
+ <InlinePreviewGutter
996
+ key="inline"
997
+ width={inlinePreviewWidth}
998
+ basePath={basePath}
999
+ mediaProvider={mediaProvider}
1000
+ />
1001
+ )}
1002
+ </div>
1003
+ )}
1004
+ {/* WYSIWYG + Preview are markdown-only surfaces — skip them
943
1005
  entirely in code or image mode so Tiptap never initializes
944
1006
  and the preview pipeline stays idle. Same always-wrapped
945
1007
  pattern as the Raw branch above so pane toggles don't
946
1008
  remount Tiptap. */}
947
- {isMarkdownMode && activeView === 'wysiwyg' && (
948
- <div className="squisq-editor-with-gutter" key="wysiwyg-shell">
949
- {outlineVisible && <OutlinePanel key="outline" width={outlineWidth} />}
950
- {isCardMode ? (
951
- <BlockCardView
952
- key="wysiwyg-card"
953
- blockCount={blockCount}
954
- activeBlockKey={activeBlockKey}
955
- onPrev={prevBlock}
956
- onNext={nextBlock}
957
- onAdd={addBlock}
958
- >
1009
+ {isMarkdownMode && activeView === 'wysiwyg' && (
1010
+ <div className="squisq-editor-with-gutter" key="wysiwyg-shell">
1011
+ {outlineVisible && (
1012
+ <OutlinePanel key="outline" width={outlineWidth} readOnly={readOnly} />
1013
+ )}
1014
+ {isCardMode ? (
1015
+ <BlockCardView
1016
+ key="wysiwyg-card"
1017
+ blockCount={blockCount}
1018
+ activeBlockKey={activeBlockKey}
1019
+ onPrev={prevBlock}
1020
+ onNext={nextBlock}
1021
+ onAdd={addBlock}
1022
+ >
1023
+ <WysiwygEditor
1024
+ key="wysiwyg-editor"
1025
+ submitOnEnter={submitOnEnter}
1026
+ placeholder={placeholder}
1027
+ readOnly={readOnly}
1028
+ />
1029
+ </BlockCardView>
1030
+ ) : (
959
1031
  <WysiwygEditor
960
1032
  key="wysiwyg-editor"
961
1033
  submitOnEnter={submitOnEnter}
962
1034
  placeholder={placeholder}
963
1035
  readOnly={readOnly}
964
1036
  />
965
- </BlockCardView>
966
- ) : (
967
- <WysiwygEditor
968
- key="wysiwyg-editor"
969
- submitOnEnter={submitOnEnter}
970
- placeholder={placeholder}
971
- readOnly={readOnly}
972
- />
973
- )}
974
- {isCardMode && inlinePreviewVisible && (
975
- <BlockPreviewPanel key="block-preview" basePath={basePath} />
976
- )}
977
- {!isCardMode && inlinePreviewVisible && (
978
- <InlinePreviewGutter
979
- key="inline"
980
- width={inlinePreviewWidth}
981
- basePath={basePath}
982
- mediaProvider={mediaProvider}
983
- />
984
- )}
985
- </div>
986
- )}
987
- {isMarkdownMode && isPreview && (
988
- <PreviewPanel basePath={basePath} workspaceContainer={workspaceContainer} />
989
- )}
990
- </div>
1037
+ )}
1038
+ {isCardMode && inlinePreviewVisible && (
1039
+ <BlockPreviewPanel key="block-preview" basePath={basePath} />
1040
+ )}
1041
+ {!isCardMode && inlinePreviewVisible && (
1042
+ <InlinePreviewGutter
1043
+ key="inline"
1044
+ width={inlinePreviewWidth}
1045
+ basePath={basePath}
1046
+ mediaProvider={mediaProvider}
1047
+ />
1048
+ )}
1049
+ </div>
1050
+ )}
1051
+ {isMarkdownMode && isPreview && (
1052
+ <PreviewPanel basePath={basePath} workspaceContainer={workspaceContainer} />
1053
+ )}
1054
+ </div>
991
1055
 
992
- {isMarkdownMode && showFiles && (
993
- <MediaBin
994
- mediaProvider={mediaProvider}
995
- isDark={isDark}
996
- refreshKey={mediaRefreshKey}
997
- onMediaUploaded={insertMediaRef}
998
- />
999
- )}
1056
+ {isMarkdownMode && showFiles && (
1057
+ <MediaBin
1058
+ mediaProvider={mediaProvider}
1059
+ isDark={isDark}
1060
+ refreshKey={mediaListRefreshKey}
1061
+ usedMediaPaths={usedMediaPaths}
1062
+ onMediaUploaded={handleMediaUploaded}
1063
+ onMediaRemoved={handleMediaRemoved}
1064
+ onCountChange={setMediaCount}
1065
+ />
1066
+ )}
1000
1067
 
1001
- {/* Docked custom-theme designer — a flex sibling of the preview, so
1068
+ {/* Docked custom-theme designer — a flex sibling of the preview, so
1002
1069
  opening it reflows the preview narrower. Renders nothing when the
1003
1070
  designer is closed. */}
1004
- {isMarkdownMode && <ThemeDesignerDock />}
1071
+ {isMarkdownMode && <ThemeDesignerDock />}
1005
1072
 
1006
- {/* Drop zone overlay — image / text drop UX is markdown-specific.
1073
+ {/* Drop zone overlay — image / text drop UX is markdown-specific.
1007
1074
  In WYSIWYG, image drops are handled directly by Tiptap's
1008
1075
  `handleDrop` (uploads to the MediaProvider and inserts an
1009
1076
  image node at the mouse position). The overlay would sit on
@@ -1011,27 +1078,28 @@ function EditorShellInner({
1011
1078
  when the dragged content is media-only on the WYSIWYG view —
1012
1079
  the user gets a one-step "drop where you want it" flow
1013
1080
  instead of a two-step "drop in bin, then insert" flow. */}
1014
- {isMarkdownMode &&
1015
- isDragging &&
1016
- !(activeView === 'wysiwyg' && dragContentType === 'media') && (
1017
- <DropZoneOverlay
1018
- dragContentType={dragContentType}
1019
- zoneProps={zoneProps}
1020
- hasMediaProvider={mediaProvider !== null}
1021
- />
1022
- )}
1023
- </div>
1081
+ {isMarkdownMode &&
1082
+ isDragging &&
1083
+ !(activeView === 'wysiwyg' && dragContentType === 'media') && (
1084
+ <DropZoneOverlay
1085
+ dragContentType={dragContentType}
1086
+ zoneProps={zoneProps}
1087
+ hasMediaProvider={mediaProvider !== null}
1088
+ />
1089
+ )}
1090
+ </div>
1024
1091
 
1025
- {/* Timeline track — horizontal strip of block + media bars shown
1092
+ {/* Timeline track — horizontal strip of block + media bars shown
1026
1093
  below the editor in Timeline view. The card editor above (driven by
1027
1094
  the block navigator) shows whichever block is selected here. */}
1028
- {isTimelineMode && <TimelineTrack />}
1095
+ {isTimelineMode && <TimelineTrack />}
1029
1096
 
1030
- {/* Status bar — word / char / line / block counts. Host can
1097
+ {/* Status bar — word / char / line / block counts. Host can
1031
1098
  suppress via `showStatusBar={false}` for embedded chat-style
1032
1099
  composers where the stats are noise. The image viewer has its
1033
1100
  own dimension/zoom status row, so suppress here too. */}
1034
- {statusBarVisible && !isImageMode && <StatusBar />}
1101
+ {statusBarVisible && !isImageMode && <StatusBar />}
1102
+ </PresentationModeProvider>
1035
1103
  </PreviewSettingsProvider>
1036
1104
  </CustomThemeProvider>
1037
1105
  <TooltipLayer />
@@ -1095,6 +1163,19 @@ function ImageEditModal({
1095
1163
  versioningAutoSaveIdleMs,
1096
1164
  shellTheme,
1097
1165
  }: ImageEditModalProps) {
1166
+ const modalRef = useRef<HTMLDivElement>(null);
1167
+ const surfaceRef = useRef<HTMLDivElement>(null);
1168
+ const onCloseRef = useRef(onClose);
1169
+ onCloseRef.current = onClose;
1170
+ const extension = relativePath.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase();
1171
+ const saveFormat: 'png' | 'jpeg' | 'webp' | null =
1172
+ extension === 'png'
1173
+ ? 'png'
1174
+ : extension === 'jpg' || extension === 'jpeg'
1175
+ ? 'jpeg'
1176
+ : extension === 'webp'
1177
+ ? 'webp'
1178
+ : null;
1098
1179
  // Each unique image path gets its own sidecar so multiple images in the
1099
1180
  // same doc can be edited independently without colliding state. When the
1100
1181
  // host didn't supply a `container`, fall back to a fresh in-memory one
@@ -1102,14 +1183,22 @@ function ImageEditModal({
1102
1183
  // gets written back through `mediaProvider`.
1103
1184
  const sidecar = useMemo(() => {
1104
1185
  const sanitized = relativePath.replace(/[^a-zA-Z0-9._-]+/g, '_');
1186
+ let hash = 2166136261;
1187
+ for (let i = 0; i < relativePath.length; i++) {
1188
+ hash ^= relativePath.charCodeAt(i);
1189
+ hash = Math.imul(hash, 16777619);
1190
+ }
1191
+ const scopedName = `${sanitized}-${(hash >>> 0).toString(36)}`;
1105
1192
  const parent: ContentContainer = container ?? new MemoryContentContainer();
1106
- return scopeContainer(parent, `.imageEdits/${sanitized}`);
1193
+ return scopeContainer(parent, `.imageEdits/${scopedName}`);
1107
1194
  }, [container, relativePath]);
1108
1195
 
1109
1196
  const [initialSrc, setInitialSrc] = useState<string | null>(null);
1110
1197
  const [resolveError, setResolveError] = useState<string | null>(null);
1111
1198
  useEffect(() => {
1112
1199
  let cancelled = false;
1200
+ setInitialSrc(null);
1201
+ setResolveError(null);
1113
1202
  mediaProvider.resolveUrl(relativePath).then(
1114
1203
  (url) => {
1115
1204
  if (!cancelled) setInitialSrc(url);
@@ -1140,15 +1229,55 @@ function ImageEditModal({
1140
1229
 
1141
1230
  // Close on Escape — global listener so it works regardless of focus.
1142
1231
  useEffect(() => {
1232
+ const previousFocus = document.activeElement as HTMLElement | null;
1233
+ const shell = modalRef.current?.closest('.squisq-editor-shell');
1234
+ const inertSiblings = shell
1235
+ ? Array.from(shell.children).filter((element) => !element.contains(modalRef.current))
1236
+ : [];
1237
+ inertSiblings.forEach((element) => {
1238
+ (element as HTMLElement).inert = true;
1239
+ });
1240
+ surfaceRef.current
1241
+ ?.querySelector<HTMLElement>(
1242
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
1243
+ )
1244
+ ?.focus();
1143
1245
  const handler = (e: KeyboardEvent) => {
1144
- if (e.key === 'Escape') onClose();
1246
+ if (e.key === 'Escape') {
1247
+ e.preventDefault();
1248
+ onCloseRef.current();
1249
+ return;
1250
+ }
1251
+ if (e.key !== 'Tab' || !surfaceRef.current) return;
1252
+ const focusable = Array.from(
1253
+ surfaceRef.current.querySelectorAll<HTMLElement>(
1254
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
1255
+ ),
1256
+ );
1257
+ if (focusable.length === 0) return;
1258
+ const first = focusable[0];
1259
+ const last = focusable[focusable.length - 1];
1260
+ if (e.shiftKey && document.activeElement === first) {
1261
+ e.preventDefault();
1262
+ last.focus();
1263
+ } else if (!e.shiftKey && document.activeElement === last) {
1264
+ e.preventDefault();
1265
+ first.focus();
1266
+ }
1145
1267
  };
1146
1268
  window.addEventListener('keydown', handler);
1147
- return () => window.removeEventListener('keydown', handler);
1148
- }, [onClose]);
1269
+ return () => {
1270
+ window.removeEventListener('keydown', handler);
1271
+ inertSiblings.forEach((element) => {
1272
+ (element as HTMLElement).inert = false;
1273
+ });
1274
+ previousFocus?.focus();
1275
+ };
1276
+ }, []);
1149
1277
 
1150
1278
  return (
1151
1279
  <div
1280
+ ref={modalRef}
1152
1281
  className="squisq-image-edit-modal"
1153
1282
  data-testid="image-edit-modal"
1154
1283
  role="dialog"
@@ -1159,7 +1288,7 @@ function ImageEditModal({
1159
1288
  if (e.target === e.currentTarget) onClose();
1160
1289
  }}
1161
1290
  >
1162
- <div className="squisq-image-edit-modal__surface">
1291
+ <div ref={surfaceRef} className="squisq-image-edit-modal__surface">
1163
1292
  <header className="squisq-image-edit-modal__header">
1164
1293
  <span className="squisq-image-edit-modal__title">Edit image</span>
1165
1294
  <span className="squisq-image-edit-modal__path">{relativePath}</span>
@@ -1178,6 +1307,10 @@ function ImageEditModal({
1178
1307
  <div className="squisq-image-edit-modal__error">
1179
1308
  Failed to load image: {resolveError}
1180
1309
  </div>
1310
+ ) : !saveFormat ? (
1311
+ <div className="squisq-image-edit-modal__error">
1312
+ Editing supports PNG, JPEG, and WebP images without changing the asset type.
1313
+ </div>
1181
1314
  ) : !initialSrc ? (
1182
1315
  <div className="squisq-image-edit-modal__loading">Loading image…</div>
1183
1316
  ) : (
@@ -1188,7 +1321,7 @@ function ImageEditModal({
1188
1321
  versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
1189
1322
  onExport={handleExport}
1190
1323
  saveBehavior="export"
1191
- saveFormat="png"
1324
+ saveFormat={saveFormat}
1192
1325
  saveLabel="Save and close"
1193
1326
  saveTitle="Save changes back to the image and close"
1194
1327
  surface={shellTheme === 'dark' ? DARK_SURFACE : LIGHT_SURFACE}