@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.
- package/dist/DropZoneOverlay.d.ts +24 -0
- package/dist/DropZoneOverlay.d.ts.map +1 -0
- package/dist/DropZoneOverlay.js +53 -0
- package/dist/DropZoneOverlay.js.map +1 -0
- package/dist/EditorContext.d.ts +10 -2
- package/dist/EditorContext.d.ts.map +1 -1
- package/dist/EditorContext.js +49 -1
- package/dist/EditorContext.js.map +1 -1
- package/dist/EditorShell.d.ts +16 -1
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +55 -8
- package/dist/EditorShell.js.map +1 -1
- package/dist/ImageNodeView.d.ts +15 -0
- package/dist/ImageNodeView.d.ts.map +1 -0
- package/dist/ImageNodeView.js +52 -0
- package/dist/ImageNodeView.js.map +1 -0
- package/dist/MediaBin.d.ts +18 -0
- package/dist/MediaBin.d.ts.map +1 -0
- package/dist/MediaBin.js +141 -0
- package/dist/MediaBin.js.map +1 -0
- package/dist/PreviewControls.d.ts +41 -0
- package/dist/PreviewControls.d.ts.map +1 -0
- package/dist/PreviewControls.js +201 -0
- package/dist/PreviewControls.js.map +1 -0
- package/dist/PreviewPanel.d.ts +7 -7
- package/dist/PreviewPanel.d.ts.map +1 -1
- package/dist/PreviewPanel.js +183 -199
- package/dist/PreviewPanel.js.map +1 -1
- package/dist/Toolbar.d.ts +12 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +4 -12
- package/dist/Toolbar.js.map +1 -1
- package/dist/WysiwygEditor.d.ts.map +1 -1
- package/dist/WysiwygEditor.js +3 -1
- package/dist/WysiwygEditor.js.map +1 -1
- package/dist/hooks/useFileDrop.d.ts +41 -0
- package/dist/hooks/useFileDrop.d.ts.map +1 -0
- package/dist/hooks/useFileDrop.js +167 -0
- package/dist/hooks/useFileDrop.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/tiptapBridge.d.ts.map +1 -1
- package/dist/tiptapBridge.js +4 -5
- package/dist/tiptapBridge.js.map +1 -1
- package/dist/utils/dropUtils.d.ts +36 -0
- package/dist/utils/dropUtils.d.ts.map +1 -0
- package/dist/utils/dropUtils.js +71 -0
- package/dist/utils/dropUtils.js.map +1 -0
- package/package.json +5 -3
- package/src/DropZoneOverlay.tsx +137 -0
- package/src/EditorContext.tsx +64 -1
- package/src/EditorShell.tsx +153 -20
- package/src/ImageNodeView.tsx +70 -0
- package/src/MediaBin.tsx +223 -0
- package/src/PreviewControls.tsx +340 -0
- package/src/PreviewPanel.tsx +216 -287
- package/src/Toolbar.tsx +40 -3
- package/src/WysiwygEditor.tsx +3 -1
- package/src/hooks/useFileDrop.ts +226 -0
- package/src/index.ts +29 -0
- package/src/styles/editor.css +349 -8
- package/src/tiptapBridge.ts +5 -6
- 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
|
+
}
|
package/src/EditorContext.tsx
CHANGED
|
@@ -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
|
|
package/src/EditorShell.tsx
CHANGED
|
@@ -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({
|
|
78
|
-
|
|
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
|
-
{
|
|
121
|
-
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
});
|