@bendyline/squisq-editor-react 1.0.1 → 1.1.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 (65) hide show
  1. package/dist/DropZoneOverlay.d.ts +24 -0
  2. package/dist/DropZoneOverlay.d.ts.map +1 -0
  3. package/dist/DropZoneOverlay.js +53 -0
  4. package/dist/DropZoneOverlay.js.map +1 -0
  5. package/dist/EditorContext.d.ts +10 -2
  6. package/dist/EditorContext.d.ts.map +1 -1
  7. package/dist/EditorContext.js +49 -1
  8. package/dist/EditorContext.js.map +1 -1
  9. package/dist/EditorShell.d.ts +16 -1
  10. package/dist/EditorShell.d.ts.map +1 -1
  11. package/dist/EditorShell.js +55 -8
  12. package/dist/EditorShell.js.map +1 -1
  13. package/dist/ImageNodeView.d.ts +15 -0
  14. package/dist/ImageNodeView.d.ts.map +1 -0
  15. package/dist/ImageNodeView.js +52 -0
  16. package/dist/ImageNodeView.js.map +1 -0
  17. package/dist/MediaBin.d.ts +18 -0
  18. package/dist/MediaBin.d.ts.map +1 -0
  19. package/dist/MediaBin.js +141 -0
  20. package/dist/MediaBin.js.map +1 -0
  21. package/dist/PreviewControls.d.ts +41 -0
  22. package/dist/PreviewControls.d.ts.map +1 -0
  23. package/dist/PreviewControls.js +201 -0
  24. package/dist/PreviewControls.js.map +1 -0
  25. package/dist/PreviewPanel.d.ts +7 -7
  26. package/dist/PreviewPanel.d.ts.map +1 -1
  27. package/dist/PreviewPanel.js +183 -199
  28. package/dist/PreviewPanel.js.map +1 -1
  29. package/dist/Toolbar.d.ts +12 -1
  30. package/dist/Toolbar.d.ts.map +1 -1
  31. package/dist/Toolbar.js +4 -12
  32. package/dist/Toolbar.js.map +1 -1
  33. package/dist/WysiwygEditor.d.ts.map +1 -1
  34. package/dist/WysiwygEditor.js +3 -1
  35. package/dist/WysiwygEditor.js.map +1 -1
  36. package/dist/hooks/useFileDrop.d.ts +41 -0
  37. package/dist/hooks/useFileDrop.d.ts.map +1 -0
  38. package/dist/hooks/useFileDrop.js +167 -0
  39. package/dist/hooks/useFileDrop.js.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +6 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/tiptapBridge.d.ts.map +1 -1
  45. package/dist/tiptapBridge.js +4 -5
  46. package/dist/tiptapBridge.js.map +1 -1
  47. package/dist/utils/dropUtils.d.ts +36 -0
  48. package/dist/utils/dropUtils.d.ts.map +1 -0
  49. package/dist/utils/dropUtils.js +71 -0
  50. package/dist/utils/dropUtils.js.map +1 -0
  51. package/package.json +5 -3
  52. package/src/DropZoneOverlay.tsx +137 -0
  53. package/src/EditorContext.tsx +64 -1
  54. package/src/EditorShell.tsx +153 -20
  55. package/src/ImageNodeView.tsx +70 -0
  56. package/src/MediaBin.tsx +223 -0
  57. package/src/PreviewControls.tsx +340 -0
  58. package/src/PreviewPanel.tsx +216 -287
  59. package/src/Toolbar.tsx +40 -3
  60. package/src/WysiwygEditor.tsx +3 -1
  61. package/src/hooks/useFileDrop.ts +226 -0
  62. package/src/index.ts +29 -0
  63. package/src/styles/editor.css +349 -8
  64. package/src/tiptapBridge.ts +5 -6
  65. package/src/utils/dropUtils.ts +88 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * DropZoneOverlay
3
+ *
4
+ * Full-editor overlay that appears when files are dragged over the editor.
5
+ * Shows contextual drop zones depending on the type of files being dragged:
6
+ * - Media files → single "Media" drop zone
7
+ * - Text files → two zones: "Insert" (at cursor) and "Replace" (all content)
8
+ * - Mixed files → all three zones
9
+ */
10
+
11
+ import { useState } from 'react';
12
+ import type { DragContentType, DropTarget, UseFileDropResult } from './hooks/useFileDrop';
13
+
14
+ export interface DropZoneOverlayProps {
15
+ /** What kind of content is being dragged */
16
+ dragContentType: DragContentType;
17
+ /** Factory that creates event props for a specific drop target */
18
+ zoneProps: UseFileDropResult['zoneProps'];
19
+ /** Whether a MediaProvider is available (disables media zone when false) */
20
+ hasMediaProvider: boolean;
21
+ }
22
+
23
+ /**
24
+ * Full-size overlay with contextual drop targets for file uploads.
25
+ * Rendered conditionally by EditorShell when files are dragged over the editor.
26
+ */
27
+ export function DropZoneOverlay({
28
+ dragContentType,
29
+ zoneProps,
30
+ hasMediaProvider,
31
+ }: DropZoneOverlayProps) {
32
+ const showMedia = dragContentType === 'media' || dragContentType === 'mixed';
33
+ const showText = dragContentType === 'text' || dragContentType === 'mixed';
34
+
35
+ return (
36
+ <div className="squisq-drop-overlay">
37
+ <div className="squisq-drop-overlay-inner">
38
+ {showMedia && (
39
+ <DropZone
40
+ target="media"
41
+ zoneProps={zoneProps}
42
+ icon="📷"
43
+ label="Media"
44
+ description={hasMediaProvider ? 'Add to file bin' : 'No file storage configured'}
45
+ disabled={!hasMediaProvider}
46
+ variant="media"
47
+ />
48
+ )}
49
+ {showText && (
50
+ <>
51
+ <DropZone
52
+ target="insert"
53
+ zoneProps={zoneProps}
54
+ icon="📋"
55
+ label="Insert"
56
+ description="Insert content at cursor"
57
+ variant="insert"
58
+ />
59
+ <DropZone
60
+ target="replace"
61
+ zoneProps={zoneProps}
62
+ icon="🔄"
63
+ label="Replace"
64
+ description="Replace all editor content"
65
+ variant="replace"
66
+ />
67
+ </>
68
+ )}
69
+ </div>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ // ─── Individual drop zone ────────────────────────────────
75
+
76
+ interface DropZoneProps {
77
+ target: DropTarget;
78
+ zoneProps: UseFileDropResult['zoneProps'];
79
+ icon: string;
80
+ label: string;
81
+ description: string;
82
+ disabled?: boolean;
83
+ variant: 'media' | 'insert' | 'replace';
84
+ }
85
+
86
+ function DropZone({
87
+ target,
88
+ zoneProps,
89
+ icon,
90
+ label,
91
+ description,
92
+ disabled,
93
+ variant,
94
+ }: DropZoneProps) {
95
+ const [isHovering, setIsHovering] = useState(false);
96
+ const props = zoneProps(target);
97
+
98
+ return (
99
+ <div
100
+ className={[
101
+ 'squisq-drop-zone',
102
+ `squisq-drop-zone--${variant}`,
103
+ isHovering && !disabled ? 'squisq-drop-zone--active' : '',
104
+ disabled ? 'squisq-drop-zone--disabled' : '',
105
+ ]
106
+ .filter(Boolean)
107
+ .join(' ')}
108
+ onDragOver={(e) => {
109
+ if (disabled) {
110
+ e.preventDefault();
111
+ return;
112
+ }
113
+ props.onDragOver(e);
114
+ }}
115
+ onDragEnter={(e) => {
116
+ e.preventDefault();
117
+ if (!disabled) setIsHovering(true);
118
+ }}
119
+ onDragLeave={(e) => {
120
+ e.preventDefault();
121
+ setIsHovering(false);
122
+ }}
123
+ onDrop={(e) => {
124
+ setIsHovering(false);
125
+ if (disabled) {
126
+ e.preventDefault();
127
+ return;
128
+ }
129
+ props.onDrop(e);
130
+ }}
131
+ >
132
+ <span className="squisq-drop-zone-icon">{icon}</span>
133
+ <span className="squisq-drop-zone-label">{label}</span>
134
+ <span className="squisq-drop-zone-desc">{description}</span>
135
+ </div>
136
+ );
137
+ }
@@ -17,12 +17,13 @@ import {
17
17
  useEffect,
18
18
  type ReactNode,
19
19
  } from 'react';
20
- import type { Doc } from '@bendyline/squisq/schemas';
20
+ import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
21
21
  import type { MarkdownDocument } from '@bendyline/squisq/markdown';
22
22
  import { parseMarkdown, stringifyMarkdown } from '@bendyline/squisq/markdown';
23
23
  import { markdownToDoc } from '@bendyline/squisq/doc';
24
24
  import type { Editor as TiptapEditor } from '@tiptap/core';
25
25
  import type { editor as MonacoEditorNs } from 'monaco-editor';
26
+ import { markdownToTiptap } from './tiptapBridge';
26
27
 
27
28
  /** Monaco standalone code editor instance type */
28
29
  type MonacoEditor = MonacoEditorNs.IStandaloneCodeEditor;
@@ -62,6 +63,10 @@ export interface EditorActions {
62
63
  setMonacoEditor: (editor: MonacoEditor | null) => void;
63
64
  /** Set the color theme */
64
65
  setTheme: (theme: EditorTheme) => void;
66
+ /** Insert text at the current cursor position in the active editor */
67
+ insertAtCursor: (text: string) => void;
68
+ /** Replace all editor content with the given text */
69
+ replaceAll: (text: string) => void;
65
70
  }
66
71
 
67
72
  export interface EditorContextValue extends EditorState, EditorActions {
@@ -69,6 +74,8 @@ export interface EditorContextValue extends EditorState, EditorActions {
69
74
  tiptapEditor: TiptapEditor | null;
70
75
  /** The live Monaco editor instance (null when Raw is not mounted) */
71
76
  monacoEditor: MonacoEditor | null;
77
+ /** MediaProvider for resolving image URLs in the WYSIWYG editor */
78
+ mediaProvider: MediaProvider | null;
72
79
  }
73
80
 
74
81
  // ─── Context ─────────────────────────────────────────────
@@ -98,6 +105,8 @@ export interface EditorProviderProps {
98
105
  articleId?: string;
99
106
  /** Color theme */
100
107
  theme?: EditorTheme;
108
+ /** MediaProvider for resolving image URLs */
109
+ mediaProvider?: MediaProvider | null;
101
110
  children: ReactNode;
102
111
  }
103
112
 
@@ -110,6 +119,7 @@ export function EditorProvider({
110
119
  initialView = 'raw',
111
120
  articleId = 'untitled',
112
121
  theme: initialTheme = 'light',
122
+ mediaProvider = null,
113
123
  children,
114
124
  }: EditorProviderProps) {
115
125
  const [markdownSource, setMarkdownSourceRaw] = useState(initialMarkdown);
@@ -187,6 +197,53 @@ export function EditorProvider({
187
197
  setMarkdownSourceRaw(source);
188
198
  }, []);
189
199
 
200
+ const insertAtCursor = useCallback(
201
+ (text: string) => {
202
+ if (activeView === 'wysiwyg' && tiptapEditor) {
203
+ // Insert as HTML so formatting is preserved
204
+ const html = markdownToTiptap(text);
205
+ tiptapEditor.chain().focus().insertContent(html).run();
206
+ } else if (activeView === 'raw' && monacoEditor) {
207
+ const position = monacoEditor.getPosition();
208
+ if (position) {
209
+ const model = monacoEditor.getModel();
210
+ if (model) {
211
+ const range = {
212
+ startLineNumber: position.lineNumber,
213
+ startColumn: position.column,
214
+ endLineNumber: position.lineNumber,
215
+ endColumn: position.column,
216
+ };
217
+ monacoEditor.executeEdits('drop', [{ range, text }]);
218
+ }
219
+ } else {
220
+ // No cursor — append
221
+ setMarkdownSourceRaw((prev) => prev + '\n\n' + text);
222
+ }
223
+ } else {
224
+ // Preview or no editor — append to end
225
+ setMarkdownSourceRaw((prev) => prev + '\n\n' + text);
226
+ }
227
+ },
228
+ [activeView, tiptapEditor, monacoEditor],
229
+ );
230
+
231
+ const replaceAll = useCallback(
232
+ (text: string) => {
233
+ setMarkdownSourceRaw(text);
234
+
235
+ // Push to editors if mounted
236
+ if (tiptapEditor) {
237
+ const html = markdownToTiptap(text);
238
+ tiptapEditor.commands.setContent(html);
239
+ }
240
+ if (monacoEditor) {
241
+ monacoEditor.setValue(text);
242
+ }
243
+ },
244
+ [tiptapEditor, monacoEditor],
245
+ );
246
+
190
247
  const setMarkdownDoc = useCallback((newDoc: MarkdownDocument) => {
191
248
  setMarkdownDocState(newDoc);
192
249
  // Stringify to update the raw source
@@ -221,12 +278,15 @@ export function EditorProvider({
221
278
  theme,
222
279
  tiptapEditor,
223
280
  monacoEditor,
281
+ mediaProvider,
224
282
  setMarkdownSource,
225
283
  setMarkdownDoc,
226
284
  setActiveView,
227
285
  setTiptapEditor,
228
286
  setMonacoEditor,
229
287
  setTheme,
288
+ insertAtCursor,
289
+ replaceAll,
230
290
  }),
231
291
  [
232
292
  markdownSource,
@@ -238,12 +298,15 @@ export function EditorProvider({
238
298
  theme,
239
299
  tiptapEditor,
240
300
  monacoEditor,
301
+ mediaProvider,
241
302
  setMarkdownSource,
242
303
  setMarkdownDoc,
243
304
  setActiveView,
244
305
  setTiptapEditor,
245
306
  setMonacoEditor,
246
307
  setTheme,
308
+ insertAtCursor,
309
+ replaceAll,
247
310
  ],
248
311
  );
249
312
 
@@ -6,13 +6,26 @@
6
6
  * in an EditorProvider for shared state.
7
7
  */
8
8
 
9
- import { useEffect } from 'react';
9
+ import { useEffect, useState, useCallback } from 'react';
10
10
  import { EditorProvider, useEditorContext, type EditorView } from './EditorContext';
11
11
  import { Toolbar } from './Toolbar';
12
12
  import { StatusBar } from './StatusBar';
13
13
  import { RawEditor } from './RawEditor';
14
14
  import { WysiwygEditor } from './WysiwygEditor';
15
15
  import { PreviewPanel } from './PreviewPanel';
16
+ import { PreviewSettingsProvider, PreviewToolbarControls } from './PreviewControls';
17
+ import { MediaBin } from './MediaBin';
18
+ import { DropZoneOverlay } from './DropZoneOverlay';
19
+ import { useFileDrop, type DropTarget } from './hooks/useFileDrop';
20
+ import {
21
+ partitionFiles,
22
+ processMediaFiles,
23
+ processTextFile,
24
+ processTextFiles,
25
+ } from './utils/dropUtils';
26
+ import type { MediaProvider } from '@bendyline/squisq/schemas';
27
+ import type { ContentContainer } from '@bendyline/squisq/storage';
28
+ import type { ReactNode } from 'react';
16
29
 
17
30
  export type { EditorTheme } from './EditorContext';
18
31
 
@@ -34,6 +47,18 @@ export interface EditorShellProps {
34
47
  className?: string;
35
48
  /** CSS height for the shell container (default: '100vh') */
36
49
  height?: string;
50
+ /** Optional MediaProvider for the Files panel. When set (even to null), a Files toggle appears in the toolbar. */
51
+ mediaProvider?: MediaProvider | null;
52
+ /** Optional ContentContainer for audio mapping (MP3 discovery + timing.json reading). */
53
+ container?: ContentContainer | null;
54
+ /** Show the Files toggle in the toolbar. Defaults to true when mediaProvider is passed. */
55
+ showFilesToggle?: boolean;
56
+ /** Content rendered at the left edge of the toolbar, before the view tabs. */
57
+ toolbarSlotLeft?: ReactNode;
58
+ /** Content rendered after the formatting controls (in the middle area of the toolbar). */
59
+ toolbarSlotAfterActions?: ReactNode;
60
+ /** Content rendered at the rightmost end of the toolbar, after all other elements. */
61
+ toolbarSlotRight?: ReactNode;
37
62
  }
38
63
 
39
64
  /**
@@ -49,19 +74,35 @@ export function EditorShell({
49
74
  theme = 'light',
50
75
  className,
51
76
  height = '100vh',
77
+ mediaProvider,
78
+ container,
79
+ showFilesToggle,
80
+ toolbarSlotLeft,
81
+ toolbarSlotAfterActions,
82
+ toolbarSlotRight,
52
83
  }: EditorShellProps) {
84
+ // Show the toggle when explicitly opted in, or when mediaProvider prop was passed at all
85
+ const filesToggleEnabled = showFilesToggle ?? mediaProvider !== undefined;
86
+
53
87
  return (
54
88
  <EditorProvider
55
89
  initialMarkdown={initialMarkdown}
56
90
  initialView={initialView}
57
91
  articleId={articleId}
58
92
  theme={theme}
93
+ mediaProvider={mediaProvider}
59
94
  >
60
95
  <EditorShellInner
61
96
  basePath={basePath}
62
97
  onChange={onChange}
63
98
  className={className}
64
99
  height={height}
100
+ mediaProvider={mediaProvider ?? null}
101
+ container={container}
102
+ filesToggleEnabled={filesToggleEnabled}
103
+ toolbarSlotLeft={toolbarSlotLeft}
104
+ toolbarSlotAfterActions={toolbarSlotAfterActions}
105
+ toolbarSlotRight={toolbarSlotRight}
65
106
  />
66
107
  </EditorProvider>
67
108
  );
@@ -72,10 +113,73 @@ interface EditorShellInnerProps {
72
113
  onChange?: (source: string) => void;
73
114
  className?: string;
74
115
  height: string;
116
+ mediaProvider: MediaProvider | null;
117
+ container?: ContentContainer | null;
118
+ filesToggleEnabled: boolean;
119
+ toolbarSlotLeft?: ReactNode;
120
+ toolbarSlotAfterActions?: ReactNode;
121
+ toolbarSlotRight?: ReactNode;
75
122
  }
76
123
 
77
- function EditorShellInner({ basePath, onChange, className, height }: EditorShellInnerProps) {
78
- const { activeView, markdownSource, theme } = useEditorContext();
124
+ function EditorShellInner({
125
+ basePath,
126
+ onChange,
127
+ className,
128
+ height,
129
+ mediaProvider,
130
+ container,
131
+ filesToggleEnabled,
132
+ toolbarSlotLeft,
133
+ toolbarSlotAfterActions,
134
+ toolbarSlotRight,
135
+ }: EditorShellInnerProps) {
136
+ const { activeView, markdownSource, doc, theme, insertAtCursor, replaceAll } = useEditorContext();
137
+ const isPreview = activeView === 'preview';
138
+ const [showFiles, setShowFiles] = useState(false);
139
+ const [mediaRefreshKey, setMediaRefreshKey] = useState(0);
140
+ const isDark = theme === 'dark';
141
+
142
+ const handleToggleFiles = useCallback(() => {
143
+ setShowFiles((prev) => !prev);
144
+ }, []);
145
+
146
+ // ── Drag-and-drop file handling ──
147
+
148
+ const handleFileDrop = useCallback(
149
+ async (files: File[], target: DropTarget) => {
150
+ try {
151
+ const { media, text } = partitionFiles(files);
152
+
153
+ // Process media files
154
+ if (media.length > 0 && mediaProvider) {
155
+ await processMediaFiles(media, mediaProvider);
156
+ setMediaRefreshKey((k) => k + 1);
157
+ // Auto-open the media bin so the user sees the new files
158
+ if (!showFiles) setShowFiles(true);
159
+ }
160
+
161
+ // Process text files
162
+ if (text.length > 0) {
163
+ if (target === 'replace') {
164
+ // Replace with first text file
165
+ const content = await processTextFile(text[0]);
166
+ replaceAll(content);
167
+ } else {
168
+ // Insert all text files concatenated
169
+ const content = await processTextFiles(text);
170
+ insertAtCursor(content);
171
+ }
172
+ }
173
+ } catch (err: unknown) {
174
+ console.error('Failed to process dropped files:', err instanceof Error ? err.message : err);
175
+ }
176
+ },
177
+ [mediaProvider, showFiles, replaceAll, insertAtCursor],
178
+ );
179
+
180
+ const { isDragging, dragContentType, containerProps, zoneProps } = useFileDrop({
181
+ onDrop: handleFileDrop,
182
+ });
79
183
 
80
184
  // Notify parent of changes
81
185
  useEffect(() => {
@@ -116,24 +220,53 @@ function EditorShellInner({ basePath, onChange, className, height }: EditorShell
116
220
  height,
117
221
  overflow: 'hidden',
118
222
  }}
223
+ {...containerProps}
119
224
  >
120
- {/* Header: Toolbar (includes view tabs) */}
121
- <div className="squisq-editor-header">
122
- <Toolbar />
123
- </div>
124
-
125
- {/* Main content area */}
126
- <div
127
- className="squisq-editor-content"
128
- style={{ flex: 1, overflow: 'hidden', position: 'relative' }}
129
- >
130
- {activeView === 'raw' && <RawEditor theme={theme === 'dark' ? 'vs-dark' : 'vs'} />}
131
- {activeView === 'wysiwyg' && <WysiwygEditor />}
132
- {activeView === 'preview' && <PreviewPanel basePath={basePath} />}
133
- </div>
134
-
135
- {/* Status bar */}
136
- <StatusBar />
225
+ <PreviewSettingsProvider doc={doc}>
226
+ {/* Header: Toolbar (includes view tabs + preview controls) */}
227
+ <div className="squisq-editor-header">
228
+ <Toolbar
229
+ showFiles={showFiles}
230
+ onToggleFiles={filesToggleEnabled ? handleToggleFiles : undefined}
231
+ slotLeft={toolbarSlotLeft}
232
+ slotAfterActions={
233
+ <>
234
+ {toolbarSlotAfterActions}
235
+ {isPreview && <PreviewToolbarControls />}
236
+ </>
237
+ }
238
+ slotRight={toolbarSlotRight}
239
+ />
240
+ </div>
241
+
242
+ {/* Main content area */}
243
+ <div
244
+ className="squisq-editor-content"
245
+ style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex' }}
246
+ >
247
+ <div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
248
+ {activeView === 'raw' && <RawEditor theme={theme === 'dark' ? 'vs-dark' : 'vs'} />}
249
+ {activeView === 'wysiwyg' && <WysiwygEditor />}
250
+ {isPreview && <PreviewPanel basePath={basePath} container={container} />}
251
+ </div>
252
+
253
+ {showFiles && (
254
+ <MediaBin mediaProvider={mediaProvider} isDark={isDark} refreshKey={mediaRefreshKey} />
255
+ )}
256
+
257
+ {/* Drop zone overlay */}
258
+ {isDragging && (
259
+ <DropZoneOverlay
260
+ dragContentType={dragContentType}
261
+ zoneProps={zoneProps}
262
+ hasMediaProvider={mediaProvider !== null}
263
+ />
264
+ )}
265
+ </div>
266
+
267
+ {/* Status bar */}
268
+ <StatusBar />
269
+ </PreviewSettingsProvider>
137
270
  </div>
138
271
  );
139
272
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ImageNodeView — Custom Tiptap NodeView for images.
3
+ *
4
+ * Resolves image `src` attributes through the EditorContext's MediaProvider,
5
+ * converting relative paths (e.g. "images/hero.jpg") to displayable blob URLs.
6
+ *
7
+ * The ProseMirror node retains the original relative path so markdown roundtrip
8
+ * is preserved — only the rendered DOM uses the resolved URL.
9
+ */
10
+
11
+ import { useEffect, useState } from 'react';
12
+ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
13
+ import type { NodeViewProps } from '@tiptap/react';
14
+ import Image from '@tiptap/extension-image';
15
+ import { useEditorContext } from './EditorContext';
16
+
17
+ function ImageComponent({ node }: NodeViewProps) {
18
+ const { src, alt, title } = node.attrs as { src: string; alt: string; title: string };
19
+ const { mediaProvider } = useEditorContext();
20
+ const [resolvedSrc, setResolvedSrc] = useState(src);
21
+
22
+ const isRelative =
23
+ src &&
24
+ !src.startsWith('blob:') &&
25
+ !src.startsWith('http') &&
26
+ !src.startsWith('data:') &&
27
+ !src.startsWith('/');
28
+
29
+ useEffect(() => {
30
+ if (!mediaProvider || !isRelative) {
31
+ setResolvedSrc(src);
32
+ return;
33
+ }
34
+
35
+ let cancelled = false;
36
+ mediaProvider.resolveUrl(src).then(
37
+ (resolved) => {
38
+ if (!cancelled) setResolvedSrc(resolved);
39
+ },
40
+ () => {
41
+ if (!cancelled) setResolvedSrc(src);
42
+ },
43
+ );
44
+
45
+ return () => {
46
+ cancelled = true;
47
+ };
48
+ }, [src, mediaProvider, isRelative]);
49
+
50
+ return (
51
+ <NodeViewWrapper as="figure" style={{ margin: '0.5em 0' }}>
52
+ <img
53
+ src={resolvedSrc}
54
+ alt={alt || ''}
55
+ title={title || undefined}
56
+ style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
57
+ />
58
+ </NodeViewWrapper>
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Image extension with a custom React NodeView that resolves URLs
64
+ * through the EditorContext's MediaProvider.
65
+ */
66
+ export const ImageWithMediaProvider = Image.extend({
67
+ addNodeView() {
68
+ return ReactNodeViewRenderer(ImageComponent);
69
+ },
70
+ });