@bendyline/squisq-editor-react 1.6.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -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';
@@ -36,6 +37,7 @@ import {
36
37
  PreviewToolbarControls,
37
38
  ThemeDesignerDock,
38
39
  } from './PreviewControls';
40
+ import { PresentationModeControl, PresentationModeProvider } from './presentation/PresentationMode';
39
41
  import { CustomThemeProvider, useDocCustomThemes } from './customThemes';
40
42
  import { MediaBin } from './MediaBin';
41
43
  import { DropZoneOverlay } from './DropZoneOverlay';
@@ -51,6 +53,7 @@ import {
51
53
  collectMediaReferencesFromMarkdown,
52
54
  removeMediaReferencesFromMarkdown,
53
55
  } from './mediaReferences';
56
+ import { filterVisibleMediaEntries } from './mediaEntries';
54
57
  import type { MediaProvider, Theme } from '@bendyline/squisq/schemas';
55
58
  import { DARK_SURFACE, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
56
59
  import type { ContentContainer } from '@bendyline/squisq/storage';
@@ -60,7 +63,8 @@ import {
60
63
  createMediaProviderFromContainer,
61
64
  } from '@bendyline/squisq/storage';
62
65
  import type { PrunePolicy, SaveVersionResult } from '@bendyline/squisq/versions';
63
- import type { CSSProperties, ReactNode } from 'react';
66
+ import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
67
+ import { MediaContext } from '@bendyline/squisq-react';
64
68
 
65
69
  export type { EditorColorScheme } from './EditorContext';
66
70
 
@@ -114,12 +118,6 @@ export interface EditorShellProps {
114
118
  * this container via `createMediaProviderFromContainer`.
115
119
  */
116
120
  workspaceContainer?: ContentContainer | null;
117
- /**
118
- * @deprecated Renamed to `workspaceContainer` to make the workspace-
119
- * vs. doc-scoped distinction explicit. Still accepted as a fallback
120
- * for now; remove in the next breaking release.
121
- */
122
- container?: ContentContainer | null;
123
121
  /**
124
122
  * Enable version history. Snapshots are stored at
125
123
  * `.versions/<basename>.<timestamp>.md` inside the same
@@ -271,6 +269,14 @@ export interface EditorShellProps {
271
269
  * the button is hidden regardless of this prop.
272
270
  */
273
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;
274
280
  /**
275
281
  * Placeholder text shown in the WYSIWYG editor while the document is
276
282
  * empty. When omitted, the editor rotates through its own generic
@@ -350,12 +356,16 @@ export interface EditorShellProps {
350
356
  */
351
357
  outlineWidth?: number;
352
358
  /**
353
- * Initial visibility of inline block-template tags on headings — the
354
- * chip rendered next to each heading in the WYSIWYG view that opens
355
- * the block-template picker. Defaults to true; the View menu can
356
- * 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'`.
357
362
  */
358
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;
359
369
  /**
360
370
  * How much of the active Squisq theme the WYSIWYG editing surface
361
371
  * mirrors. Defaults to `'fonts'` — the historical behavior of
@@ -367,7 +377,8 @@ export interface EditorShellProps {
367
377
  * Bundled view preferences — a serializable JSON blob covering the
368
378
  * runtime-toggleable view options surfaced in the View menu. When
369
379
  * provided, fields here override the corresponding individual props
370
- * (`outline`, `inlinePreview`, `showStatusBar`). Pair with
380
+ * (`outline`, `inlinePreview`, `showStatusBar`, `blockTagVisibility`,
381
+ * `blockTags`). Pair with
371
382
  * {@link onViewPreferencesChange} to externalize storage of these
372
383
  * preferences in the host.
373
384
  */
@@ -405,7 +416,6 @@ export function EditorShell({
405
416
  maxHeight,
406
417
  mediaProvider,
407
418
  workspaceContainer,
408
- container,
409
419
  allowVersioning = false,
410
420
  versionBasename,
411
421
  versioningPrunePolicy,
@@ -428,6 +438,7 @@ export function EditorShell({
428
438
  mentionProvider,
429
439
  documentLinkProvider,
430
440
  allowRecording = true,
441
+ allowNarrate = true,
431
442
  placeholder,
432
443
  readOnly = false,
433
444
  imageSrc,
@@ -439,13 +450,14 @@ export function EditorShell({
439
450
  inlinePreviewWidth = 320,
440
451
  outline = false,
441
452
  outlineWidth,
442
- blockTags = true,
453
+ blockTags,
454
+ blockTagVisibility,
443
455
  themeInheritance = 'fonts',
444
456
  viewPreferences,
445
457
  onViewPreferencesChange,
446
458
  themeOverride = null,
447
459
  }: EditorShellProps) {
448
- const effectiveContainer = workspaceContainer ?? container ?? null;
460
+ const effectiveContainer = workspaceContainer ?? null;
449
461
 
450
462
  // If the host gave us a `workspaceContainer` but no explicit `mediaProvider`,
451
463
  // derive one automatically. Without this, drag-and-drop of an image
@@ -466,65 +478,69 @@ export function EditorShell({
466
478
  !showPlayTab && initialView === 'preview' ? 'wysiwyg' : initialView;
467
479
 
468
480
  return (
469
- <EditorProvider
470
- initialMarkdown={initialMarkdown}
471
- initialView={effectiveInitialView}
472
- articleId={articleId}
473
- colorScheme={colorScheme}
474
- workspaceContainer={effectiveContainer}
475
- allowVersioning={allowVersioning}
476
- versionBasename={versionBasename}
477
- versioningPrunePolicy={versioningPrunePolicy}
478
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
479
- onSaveVersion={onSaveVersion}
480
- mediaProvider={effectiveMediaProvider}
481
- imageDisplayMode={imageDisplayMode}
482
- mentionProvider={mentionProvider}
483
- documentLinkProvider={documentLinkProvider}
484
- allowRecording={allowRecording}
485
- fileName={fileName}
486
- language={language}
487
- inlinePreview={inlinePreview}
488
- showStatusBar={showStatusBar}
489
- outline={outline}
490
- blockTags={blockTags}
491
- themeInheritance={themeInheritance}
492
- viewPreferences={viewPreferences}
493
- onViewPreferencesChange={onViewPreferencesChange}
494
- >
495
- <EditorShellInner
496
- basePath={basePath}
497
- onChange={onChange}
498
- className={className}
499
- height={height}
500
- minHeight={minHeight}
501
- maxHeight={maxHeight}
502
- placeholder={placeholder}
503
- mediaProvider={effectiveMediaProvider ?? null}
481
+ <MediaContext.Provider value={effectiveMediaProvider ?? null}>
482
+ <EditorProvider
483
+ initialMarkdown={initialMarkdown}
484
+ initialView={effectiveInitialView}
485
+ articleId={articleId}
486
+ colorScheme={colorScheme}
504
487
  workspaceContainer={effectiveContainer}
505
- filesToggleEnabled={filesToggleEnabled}
506
- toolbarSlotLeft={toolbarSlotLeft}
507
- toolbarSlotAfterActions={toolbarSlotAfterActions}
508
- toolbarSlotRight={toolbarSlotRight}
509
- showPlayTab={showPlayTab}
510
- submitOnEnter={submitOnEnter}
511
- codeContext={codeContext}
512
- fullWidth={fullWidth}
513
- uxFont={uxFont}
514
- thinMargins={thinMargins}
515
- readOnly={readOnly}
516
- imageSrc={imageSrc}
517
- imageAlt={imageAlt}
518
- imageMode={imageMode}
519
- imageEditorContainer={imageEditorContainer}
520
- onImageExport={onImageExport}
521
488
  allowVersioning={allowVersioning}
489
+ versionBasename={versionBasename}
490
+ versioningPrunePolicy={versioningPrunePolicy}
522
491
  versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
523
- inlinePreviewWidth={inlinePreviewWidth}
524
- outlineWidth={outlineWidth}
525
- themeOverride={themeOverride}
526
- />
527
- </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>
528
544
  );
529
545
  }
530
546
 
@@ -593,6 +609,7 @@ function EditorShellInner({
593
609
  outlineWidth,
594
610
  themeOverride,
595
611
  }: EditorShellInnerProps) {
612
+ const shellRef = useRef<HTMLDivElement>(null);
596
613
  const {
597
614
  activeView,
598
615
  markdownSource,
@@ -662,7 +679,7 @@ function EditorShellInner({
662
679
  let cancelled = false;
663
680
  mediaProvider.listMedia().then(
664
681
  (entries) => {
665
- if (!cancelled) setMediaCount(entries.length);
682
+ if (!cancelled) setMediaCount(filterVisibleMediaEntries(entries).length);
666
683
  },
667
684
  (err: unknown) => {
668
685
  if (!cancelled) {
@@ -699,7 +716,7 @@ function EditorShellInner({
699
716
  *
700
717
  * Without this, upload-via-MediaBin and upload-via-drop both
701
718
  * added the file to the bin and nowhere else — the composer sent
702
- * an empty body and the downstream gezel reported "nothing came
719
+ * an empty body and the downstream consumer reported "nothing came
703
720
  * through."
704
721
  */
705
722
  const insertMediaRef = useCallback(
@@ -800,6 +817,7 @@ function EditorShellInner({
800
817
 
801
818
  const { isDragging, dragContentType, containerProps, zoneProps } = useFileDrop({
802
819
  onDrop: handleFileDrop,
820
+ enabled: !readOnly,
803
821
  });
804
822
 
805
823
  // Notify parent of changes
@@ -807,35 +825,28 @@ function EditorShellInner({
807
825
  onChange?.(markdownSource);
808
826
  }, [markdownSource, onChange]);
809
827
 
810
- // Keyboard shortcuts for view switching
811
- useEffect(() => {
812
- const handler = (e: KeyboardEvent) => {
813
- if (e.ctrlKey || e.metaKey) {
814
- switch (e.key) {
815
- case '1':
816
- e.preventDefault();
817
- document.querySelector<HTMLButtonElement>('[data-view="wysiwyg"]')?.click();
818
- break;
819
- case '2':
820
- e.preventDefault();
821
- document.querySelector<HTMLButtonElement>('[data-view="raw"]')?.click();
822
- break;
823
- case '3':
824
- if (!showPlayTab) return;
825
- e.preventDefault();
826
- document.querySelector<HTMLButtonElement>('[data-view="preview"]')?.click();
827
- break;
828
- }
829
- }
830
- };
831
- window.addEventListener('keydown', handler);
832
- return () => window.removeEventListener('keydown', handler);
833
- }, [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
+ );
834
843
 
835
844
  const autoGrow = minHeight !== undefined || maxHeight !== undefined;
836
845
 
837
846
  return (
838
847
  <div
848
+ ref={shellRef}
849
+ onKeyDown={handleShellKeyDown}
839
850
  className={`squisq-editor-shell ${className || ''}`}
840
851
  data-theme={colorScheme}
841
852
  data-full-width={fullWidth ? 'true' : undefined}
@@ -866,85 +877,103 @@ function EditorShellInner({
866
877
  >
867
878
  <CustomThemeProvider docThemes={docThemes} onDocThemesChange={onDocThemesChange}>
868
879
  <PreviewSettingsProvider doc={doc} themeOverride={themeOverride}>
869
- {/* 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
870
882
  with a minimal slot bar — view tabs, formatting, and preview
871
883
  controls don't apply to a binary asset. */}
872
- {isImageMode ? (
873
- (toolbarSlotLeft || toolbarSlotRight) && (
874
- <div className="squisq-editor-header squisq-editor-header--image">
875
- {toolbarSlotLeft}
876
- <div style={{ flex: 1 }} />
877
- {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
+ />
878
912
  </div>
879
- )
880
- ) : (
881
- <div className="squisq-editor-header">
882
- <Toolbar
883
- showFiles={showFiles}
884
- fileCount={mediaCount}
885
- onToggleFiles={!isCodeMode && filesToggleEnabled ? handleToggleFiles : undefined}
886
- slotLeft={toolbarSlotLeft}
887
- slotAfterTabs={!isCodeMode && isPreview && <PreviewToolbarControls />}
888
- slotAfterActions={toolbarSlotAfterActions}
889
- slotRight={toolbarSlotRight}
890
- showPlayTab={showPlayTab}
891
- />
892
- </div>
893
- )}
913
+ )}
894
914
 
895
- {/* Main content area */}
896
- <div
897
- className="squisq-editor-content"
898
- style={{
899
- flex: autoGrow ? '1 1 auto' : 1,
900
- overflowY: autoGrow ? 'auto' : 'hidden',
901
- overflowX: 'hidden',
902
- minHeight: 0,
903
- position: 'relative',
904
- display: 'flex',
905
- }}
906
- >
915
+ {/* Main content area */}
907
916
  <div
917
+ className="squisq-editor-content"
908
918
  style={{
909
919
  flex: autoGrow ? '1 1 auto' : 1,
910
- overflow: autoGrow ? 'visible' : 'hidden',
920
+ overflowY: autoGrow ? 'auto' : 'hidden',
921
+ overflowX: 'hidden',
911
922
  minHeight: 0,
912
923
  position: 'relative',
924
+ display: 'flex',
913
925
  }}
914
926
  >
915
- {isImageMode &&
916
- imageSrc &&
917
- (imageMode === 'edit' && imageEditorContainer ? (
918
- <ImageEditor
919
- filesContainer={imageEditorContainer}
920
- initialSrc={imageSrc}
921
- allowVersioning={allowVersioning}
922
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
923
- onExport={onImageExport}
924
- surface={colorScheme === 'dark' ? DARK_SURFACE : LIGHT_SURFACE}
925
- />
926
- ) : (
927
- <ImageViewer src={imageSrc} alt={imageAlt} theme={colorScheme} />
928
- ))}
929
- {/* 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`
930
950
  so toggling a pane on/off doesn't change the editor's tree
931
951
  position — Monaco stays mounted and `monacoEditor` in
932
952
  context stays stable, which is what `useHeadingLayout` needs
933
953
  to compute positions. */}
934
- {!isImageMode && activeView === 'raw' && (
935
- <div className="squisq-editor-with-gutter" key="raw-shell">
936
- {isMarkdownMode && outlineVisible && (
937
- <OutlinePanel key="outline" width={outlineWidth} />
938
- )}
939
- {isCardMode ? (
940
- <BlockCardView
941
- key="raw-card"
942
- blockCount={blockCount}
943
- activeBlockKey={activeBlockKey}
944
- onPrev={prevBlock}
945
- onNext={nextBlock}
946
- onAdd={addBlock}
947
- >
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
+ ) : (
948
977
  <div key="raw-editor" className="squisq-raw-editor-container">
949
978
  <RawEditor
950
979
  monacoTheme={colorScheme === 'dark' ? 'vs-dark' : 'vs'}
@@ -952,103 +981,96 @@ function EditorShellInner({
952
981
  readOnly={readOnly}
953
982
  />
954
983
  </div>
955
- </BlockCardView>
956
- ) : (
957
- <div key="raw-editor" className="squisq-raw-editor-container">
958
- <RawEditor
959
- monacoTheme={colorScheme === 'dark' ? 'vs-dark' : 'vs'}
960
- submitOnEnter={submitOnEnter}
961
- readOnly={readOnly}
962
- />
963
- </div>
964
- )}
965
- {/* Renders nothing in normal flow — portals context
984
+ )}
985
+ {/* Renders nothing in normal flow — portals context
966
986
  sections into Monaco view zones via the context's
967
987
  monacoEditor. Code mode only. */}
968
- {isCodeMode && codeContext && (
969
- <CodeContextZones key="code-context" options={codeContext} />
970
- )}
971
- {isMarkdownMode && isCardMode && inlinePreviewVisible && (
972
- <BlockPreviewPanel key="block-preview" basePath={basePath} />
973
- )}
974
- {isMarkdownMode && !isCardMode && inlinePreviewVisible && (
975
- <InlinePreviewGutter
976
- key="inline"
977
- width={inlinePreviewWidth}
978
- basePath={basePath}
979
- mediaProvider={mediaProvider}
980
- />
981
- )}
982
- </div>
983
- )}
984
- {/* 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
985
1005
  entirely in code or image mode so Tiptap never initializes
986
1006
  and the preview pipeline stays idle. Same always-wrapped
987
1007
  pattern as the Raw branch above so pane toggles don't
988
1008
  remount Tiptap. */}
989
- {isMarkdownMode && activeView === 'wysiwyg' && (
990
- <div className="squisq-editor-with-gutter" key="wysiwyg-shell">
991
- {outlineVisible && <OutlinePanel key="outline" width={outlineWidth} />}
992
- {isCardMode ? (
993
- <BlockCardView
994
- key="wysiwyg-card"
995
- blockCount={blockCount}
996
- activeBlockKey={activeBlockKey}
997
- onPrev={prevBlock}
998
- onNext={nextBlock}
999
- onAdd={addBlock}
1000
- >
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
+ ) : (
1001
1031
  <WysiwygEditor
1002
1032
  key="wysiwyg-editor"
1003
1033
  submitOnEnter={submitOnEnter}
1004
1034
  placeholder={placeholder}
1005
1035
  readOnly={readOnly}
1006
1036
  />
1007
- </BlockCardView>
1008
- ) : (
1009
- <WysiwygEditor
1010
- key="wysiwyg-editor"
1011
- submitOnEnter={submitOnEnter}
1012
- placeholder={placeholder}
1013
- readOnly={readOnly}
1014
- />
1015
- )}
1016
- {isCardMode && inlinePreviewVisible && (
1017
- <BlockPreviewPanel key="block-preview" basePath={basePath} />
1018
- )}
1019
- {!isCardMode && inlinePreviewVisible && (
1020
- <InlinePreviewGutter
1021
- key="inline"
1022
- width={inlinePreviewWidth}
1023
- basePath={basePath}
1024
- mediaProvider={mediaProvider}
1025
- />
1026
- )}
1027
- </div>
1028
- )}
1029
- {isMarkdownMode && isPreview && (
1030
- <PreviewPanel basePath={basePath} workspaceContainer={workspaceContainer} />
1031
- )}
1032
- </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>
1033
1055
 
1034
- {isMarkdownMode && showFiles && (
1035
- <MediaBin
1036
- mediaProvider={mediaProvider}
1037
- isDark={isDark}
1038
- refreshKey={mediaListRefreshKey}
1039
- usedMediaPaths={usedMediaPaths}
1040
- onMediaUploaded={handleMediaUploaded}
1041
- onMediaRemoved={handleMediaRemoved}
1042
- onCountChange={setMediaCount}
1043
- />
1044
- )}
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
+ )}
1045
1067
 
1046
- {/* Docked custom-theme designer — a flex sibling of the preview, so
1068
+ {/* Docked custom-theme designer — a flex sibling of the preview, so
1047
1069
  opening it reflows the preview narrower. Renders nothing when the
1048
1070
  designer is closed. */}
1049
- {isMarkdownMode && <ThemeDesignerDock />}
1071
+ {isMarkdownMode && <ThemeDesignerDock />}
1050
1072
 
1051
- {/* Drop zone overlay — image / text drop UX is markdown-specific.
1073
+ {/* Drop zone overlay — image / text drop UX is markdown-specific.
1052
1074
  In WYSIWYG, image drops are handled directly by Tiptap's
1053
1075
  `handleDrop` (uploads to the MediaProvider and inserts an
1054
1076
  image node at the mouse position). The overlay would sit on
@@ -1056,27 +1078,28 @@ function EditorShellInner({
1056
1078
  when the dragged content is media-only on the WYSIWYG view —
1057
1079
  the user gets a one-step "drop where you want it" flow
1058
1080
  instead of a two-step "drop in bin, then insert" flow. */}
1059
- {isMarkdownMode &&
1060
- isDragging &&
1061
- !(activeView === 'wysiwyg' && dragContentType === 'media') && (
1062
- <DropZoneOverlay
1063
- dragContentType={dragContentType}
1064
- zoneProps={zoneProps}
1065
- hasMediaProvider={mediaProvider !== null}
1066
- />
1067
- )}
1068
- </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>
1069
1091
 
1070
- {/* Timeline track — horizontal strip of block + media bars shown
1092
+ {/* Timeline track — horizontal strip of block + media bars shown
1071
1093
  below the editor in Timeline view. The card editor above (driven by
1072
1094
  the block navigator) shows whichever block is selected here. */}
1073
- {isTimelineMode && <TimelineTrack />}
1095
+ {isTimelineMode && <TimelineTrack />}
1074
1096
 
1075
- {/* Status bar — word / char / line / block counts. Host can
1097
+ {/* Status bar — word / char / line / block counts. Host can
1076
1098
  suppress via `showStatusBar={false}` for embedded chat-style
1077
1099
  composers where the stats are noise. The image viewer has its
1078
1100
  own dimension/zoom status row, so suppress here too. */}
1079
- {statusBarVisible && !isImageMode && <StatusBar />}
1101
+ {statusBarVisible && !isImageMode && <StatusBar />}
1102
+ </PresentationModeProvider>
1080
1103
  </PreviewSettingsProvider>
1081
1104
  </CustomThemeProvider>
1082
1105
  <TooltipLayer />
@@ -1140,6 +1163,19 @@ function ImageEditModal({
1140
1163
  versioningAutoSaveIdleMs,
1141
1164
  shellTheme,
1142
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;
1143
1179
  // Each unique image path gets its own sidecar so multiple images in the
1144
1180
  // same doc can be edited independently without colliding state. When the
1145
1181
  // host didn't supply a `container`, fall back to a fresh in-memory one
@@ -1147,14 +1183,22 @@ function ImageEditModal({
1147
1183
  // gets written back through `mediaProvider`.
1148
1184
  const sidecar = useMemo(() => {
1149
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)}`;
1150
1192
  const parent: ContentContainer = container ?? new MemoryContentContainer();
1151
- return scopeContainer(parent, `.imageEdits/${sanitized}`);
1193
+ return scopeContainer(parent, `.imageEdits/${scopedName}`);
1152
1194
  }, [container, relativePath]);
1153
1195
 
1154
1196
  const [initialSrc, setInitialSrc] = useState<string | null>(null);
1155
1197
  const [resolveError, setResolveError] = useState<string | null>(null);
1156
1198
  useEffect(() => {
1157
1199
  let cancelled = false;
1200
+ setInitialSrc(null);
1201
+ setResolveError(null);
1158
1202
  mediaProvider.resolveUrl(relativePath).then(
1159
1203
  (url) => {
1160
1204
  if (!cancelled) setInitialSrc(url);
@@ -1185,15 +1229,55 @@ function ImageEditModal({
1185
1229
 
1186
1230
  // Close on Escape — global listener so it works regardless of focus.
1187
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();
1188
1245
  const handler = (e: KeyboardEvent) => {
1189
- 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
+ }
1190
1267
  };
1191
1268
  window.addEventListener('keydown', handler);
1192
- return () => window.removeEventListener('keydown', handler);
1193
- }, [onClose]);
1269
+ return () => {
1270
+ window.removeEventListener('keydown', handler);
1271
+ inertSiblings.forEach((element) => {
1272
+ (element as HTMLElement).inert = false;
1273
+ });
1274
+ previousFocus?.focus();
1275
+ };
1276
+ }, []);
1194
1277
 
1195
1278
  return (
1196
1279
  <div
1280
+ ref={modalRef}
1197
1281
  className="squisq-image-edit-modal"
1198
1282
  data-testid="image-edit-modal"
1199
1283
  role="dialog"
@@ -1204,7 +1288,7 @@ function ImageEditModal({
1204
1288
  if (e.target === e.currentTarget) onClose();
1205
1289
  }}
1206
1290
  >
1207
- <div className="squisq-image-edit-modal__surface">
1291
+ <div ref={surfaceRef} className="squisq-image-edit-modal__surface">
1208
1292
  <header className="squisq-image-edit-modal__header">
1209
1293
  <span className="squisq-image-edit-modal__title">Edit image</span>
1210
1294
  <span className="squisq-image-edit-modal__path">{relativePath}</span>
@@ -1223,6 +1307,10 @@ function ImageEditModal({
1223
1307
  <div className="squisq-image-edit-modal__error">
1224
1308
  Failed to load image: {resolveError}
1225
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>
1226
1314
  ) : !initialSrc ? (
1227
1315
  <div className="squisq-image-edit-modal__loading">Loading image…</div>
1228
1316
  ) : (
@@ -1233,7 +1321,7 @@ function ImageEditModal({
1233
1321
  versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
1234
1322
  onExport={handleExport}
1235
1323
  saveBehavior="export"
1236
- saveFormat="png"
1324
+ saveFormat={saveFormat}
1237
1325
  saveLabel="Save and close"
1238
1326
  saveTitle="Save changes back to the image and close"
1239
1327
  surface={shellTheme === 'dark' ? DARK_SURFACE : LIGHT_SURFACE}