@bendyline/squisq-editor-react 1.0.1 → 1.1.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.
- 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 +4 -0
- package/dist/EditorContext.d.ts.map +1 -1
- package/dist/EditorContext.js +46 -0
- package/dist/EditorContext.js.map +1 -1
- package/dist/EditorShell.d.ts +6 -1
- package/dist/EditorShell.d.ts.map +1 -1
- package/dist/EditorShell.js +51 -6
- package/dist/EditorShell.js.map +1 -1
- 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/Toolbar.d.ts +5 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +2 -2
- package/dist/Toolbar.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 +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.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 +4 -3
- package/src/DropZoneOverlay.tsx +137 -0
- package/src/EditorContext.tsx +56 -0
- package/src/EditorShell.tsx +102 -8
- package/src/MediaBin.tsx +223 -0
- package/src/Toolbar.tsx +21 -1
- package/src/hooks/useFileDrop.ts +226 -0
- package/src/index.ts +23 -0
- package/src/styles/editor.css +318 -0
- package/src/utils/dropUtils.ts +88 -0
package/src/EditorContext.tsx
CHANGED
|
@@ -23,6 +23,7 @@ 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 {
|
|
@@ -187,6 +192,53 @@ export function EditorProvider({
|
|
|
187
192
|
setMarkdownSourceRaw(source);
|
|
188
193
|
}, []);
|
|
189
194
|
|
|
195
|
+
const insertAtCursor = useCallback(
|
|
196
|
+
(text: string) => {
|
|
197
|
+
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
198
|
+
// Insert as HTML so formatting is preserved
|
|
199
|
+
const html = markdownToTiptap(text);
|
|
200
|
+
tiptapEditor.chain().focus().insertContent(html).run();
|
|
201
|
+
} else if (activeView === 'raw' && monacoEditor) {
|
|
202
|
+
const position = monacoEditor.getPosition();
|
|
203
|
+
if (position) {
|
|
204
|
+
const model = monacoEditor.getModel();
|
|
205
|
+
if (model) {
|
|
206
|
+
const range = {
|
|
207
|
+
startLineNumber: position.lineNumber,
|
|
208
|
+
startColumn: position.column,
|
|
209
|
+
endLineNumber: position.lineNumber,
|
|
210
|
+
endColumn: position.column,
|
|
211
|
+
};
|
|
212
|
+
monacoEditor.executeEdits('drop', [{ range, text }]);
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
// No cursor — append
|
|
216
|
+
setMarkdownSourceRaw((prev) => prev + '\n\n' + text);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// Preview or no editor — append to end
|
|
220
|
+
setMarkdownSourceRaw((prev) => prev + '\n\n' + text);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
[activeView, tiptapEditor, monacoEditor],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const replaceAll = useCallback(
|
|
227
|
+
(text: string) => {
|
|
228
|
+
setMarkdownSourceRaw(text);
|
|
229
|
+
|
|
230
|
+
// Push to editors if mounted
|
|
231
|
+
if (tiptapEditor) {
|
|
232
|
+
const html = markdownToTiptap(text);
|
|
233
|
+
tiptapEditor.commands.setContent(html);
|
|
234
|
+
}
|
|
235
|
+
if (monacoEditor) {
|
|
236
|
+
monacoEditor.setValue(text);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
[tiptapEditor, monacoEditor],
|
|
240
|
+
);
|
|
241
|
+
|
|
190
242
|
const setMarkdownDoc = useCallback((newDoc: MarkdownDocument) => {
|
|
191
243
|
setMarkdownDocState(newDoc);
|
|
192
244
|
// Stringify to update the raw source
|
|
@@ -227,6 +279,8 @@ export function EditorProvider({
|
|
|
227
279
|
setTiptapEditor,
|
|
228
280
|
setMonacoEditor,
|
|
229
281
|
setTheme,
|
|
282
|
+
insertAtCursor,
|
|
283
|
+
replaceAll,
|
|
230
284
|
}),
|
|
231
285
|
[
|
|
232
286
|
markdownSource,
|
|
@@ -244,6 +298,8 @@ export function EditorProvider({
|
|
|
244
298
|
setTiptapEditor,
|
|
245
299
|
setMonacoEditor,
|
|
246
300
|
setTheme,
|
|
301
|
+
insertAtCursor,
|
|
302
|
+
replaceAll,
|
|
247
303
|
],
|
|
248
304
|
);
|
|
249
305
|
|
package/src/EditorShell.tsx
CHANGED
|
@@ -6,13 +6,23 @@
|
|
|
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 { MediaBin } from './MediaBin';
|
|
17
|
+
import { DropZoneOverlay } from './DropZoneOverlay';
|
|
18
|
+
import { useFileDrop, type DropTarget } from './hooks/useFileDrop';
|
|
19
|
+
import {
|
|
20
|
+
partitionFiles,
|
|
21
|
+
processMediaFiles,
|
|
22
|
+
processTextFile,
|
|
23
|
+
processTextFiles,
|
|
24
|
+
} from './utils/dropUtils';
|
|
25
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
16
26
|
|
|
17
27
|
export type { EditorTheme } from './EditorContext';
|
|
18
28
|
|
|
@@ -34,6 +44,10 @@ export interface EditorShellProps {
|
|
|
34
44
|
className?: string;
|
|
35
45
|
/** CSS height for the shell container (default: '100vh') */
|
|
36
46
|
height?: string;
|
|
47
|
+
/** Optional MediaProvider for the Files panel. When set (even to null), a Files toggle appears in the toolbar. */
|
|
48
|
+
mediaProvider?: MediaProvider | null;
|
|
49
|
+
/** Show the Files toggle in the toolbar. Defaults to true when mediaProvider is passed. */
|
|
50
|
+
showFilesToggle?: boolean;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
/**
|
|
@@ -49,7 +63,12 @@ export function EditorShell({
|
|
|
49
63
|
theme = 'light',
|
|
50
64
|
className,
|
|
51
65
|
height = '100vh',
|
|
66
|
+
mediaProvider,
|
|
67
|
+
showFilesToggle,
|
|
52
68
|
}: EditorShellProps) {
|
|
69
|
+
// Show the toggle when explicitly opted in, or when mediaProvider prop was passed at all
|
|
70
|
+
const filesToggleEnabled = showFilesToggle ?? mediaProvider !== undefined;
|
|
71
|
+
|
|
53
72
|
return (
|
|
54
73
|
<EditorProvider
|
|
55
74
|
initialMarkdown={initialMarkdown}
|
|
@@ -62,6 +81,8 @@ export function EditorShell({
|
|
|
62
81
|
onChange={onChange}
|
|
63
82
|
className={className}
|
|
64
83
|
height={height}
|
|
84
|
+
mediaProvider={mediaProvider ?? null}
|
|
85
|
+
filesToggleEnabled={filesToggleEnabled}
|
|
65
86
|
/>
|
|
66
87
|
</EditorProvider>
|
|
67
88
|
);
|
|
@@ -72,10 +93,64 @@ interface EditorShellInnerProps {
|
|
|
72
93
|
onChange?: (source: string) => void;
|
|
73
94
|
className?: string;
|
|
74
95
|
height: string;
|
|
96
|
+
mediaProvider: MediaProvider | null;
|
|
97
|
+
filesToggleEnabled: boolean;
|
|
75
98
|
}
|
|
76
99
|
|
|
77
|
-
function EditorShellInner({
|
|
78
|
-
|
|
100
|
+
function EditorShellInner({
|
|
101
|
+
basePath,
|
|
102
|
+
onChange,
|
|
103
|
+
className,
|
|
104
|
+
height,
|
|
105
|
+
mediaProvider,
|
|
106
|
+
filesToggleEnabled,
|
|
107
|
+
}: EditorShellInnerProps) {
|
|
108
|
+
const { activeView, markdownSource, theme, insertAtCursor, replaceAll } = useEditorContext();
|
|
109
|
+
const [showFiles, setShowFiles] = useState(false);
|
|
110
|
+
const [mediaRefreshKey, setMediaRefreshKey] = useState(0);
|
|
111
|
+
const isDark = theme === 'dark';
|
|
112
|
+
|
|
113
|
+
const handleToggleFiles = useCallback(() => {
|
|
114
|
+
setShowFiles((prev) => !prev);
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
// ── Drag-and-drop file handling ──
|
|
118
|
+
|
|
119
|
+
const handleFileDrop = useCallback(
|
|
120
|
+
async (files: File[], target: DropTarget) => {
|
|
121
|
+
try {
|
|
122
|
+
const { media, text } = partitionFiles(files);
|
|
123
|
+
|
|
124
|
+
// Process media files
|
|
125
|
+
if (media.length > 0 && mediaProvider) {
|
|
126
|
+
await processMediaFiles(media, mediaProvider);
|
|
127
|
+
setMediaRefreshKey((k) => k + 1);
|
|
128
|
+
// Auto-open the media bin so the user sees the new files
|
|
129
|
+
if (!showFiles) setShowFiles(true);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Process text files
|
|
133
|
+
if (text.length > 0) {
|
|
134
|
+
if (target === 'replace') {
|
|
135
|
+
// Replace with first text file
|
|
136
|
+
const content = await processTextFile(text[0]);
|
|
137
|
+
replaceAll(content);
|
|
138
|
+
} else {
|
|
139
|
+
// Insert all text files concatenated
|
|
140
|
+
const content = await processTextFiles(text);
|
|
141
|
+
insertAtCursor(content);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch (err: unknown) {
|
|
145
|
+
console.error('Failed to process dropped files:', err instanceof Error ? err.message : err);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
[mediaProvider, showFiles, replaceAll, insertAtCursor],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const { isDragging, dragContentType, containerProps, zoneProps } = useFileDrop({
|
|
152
|
+
onDrop: handleFileDrop,
|
|
153
|
+
});
|
|
79
154
|
|
|
80
155
|
// Notify parent of changes
|
|
81
156
|
useEffect(() => {
|
|
@@ -116,20 +191,39 @@ function EditorShellInner({ basePath, onChange, className, height }: EditorShell
|
|
|
116
191
|
height,
|
|
117
192
|
overflow: 'hidden',
|
|
118
193
|
}}
|
|
194
|
+
{...containerProps}
|
|
119
195
|
>
|
|
120
196
|
{/* Header: Toolbar (includes view tabs) */}
|
|
121
197
|
<div className="squisq-editor-header">
|
|
122
|
-
<Toolbar
|
|
198
|
+
<Toolbar
|
|
199
|
+
showFiles={showFiles}
|
|
200
|
+
onToggleFiles={filesToggleEnabled ? handleToggleFiles : undefined}
|
|
201
|
+
/>
|
|
123
202
|
</div>
|
|
124
203
|
|
|
125
204
|
{/* Main content area */}
|
|
126
205
|
<div
|
|
127
206
|
className="squisq-editor-content"
|
|
128
|
-
style={{ flex: 1, overflow: 'hidden', position: 'relative' }}
|
|
207
|
+
style={{ flex: 1, overflow: 'hidden', position: 'relative', display: 'flex' }}
|
|
129
208
|
>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
209
|
+
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
|
210
|
+
{activeView === 'raw' && <RawEditor theme={theme === 'dark' ? 'vs-dark' : 'vs'} />}
|
|
211
|
+
{activeView === 'wysiwyg' && <WysiwygEditor />}
|
|
212
|
+
{activeView === 'preview' && <PreviewPanel basePath={basePath} />}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{showFiles && (
|
|
216
|
+
<MediaBin mediaProvider={mediaProvider} isDark={isDark} refreshKey={mediaRefreshKey} />
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{/* Drop zone overlay */}
|
|
220
|
+
{isDragging && (
|
|
221
|
+
<DropZoneOverlay
|
|
222
|
+
dragContentType={dragContentType}
|
|
223
|
+
zoneProps={zoneProps}
|
|
224
|
+
hasMediaProvider={mediaProvider !== null}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
133
227
|
</div>
|
|
134
228
|
|
|
135
229
|
{/* Status bar */}
|
package/src/MediaBin.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaBin
|
|
3
|
+
*
|
|
4
|
+
* Toggleable side panel that displays files associated with the current
|
|
5
|
+
* content. Shows image thumbnails, icons for other types, file sizes,
|
|
6
|
+
* and provides an upload button to add new media.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
10
|
+
import type { MediaProvider, MediaEntry } from '@bendyline/squisq/schemas';
|
|
11
|
+
|
|
12
|
+
// ============================================
|
|
13
|
+
// Types
|
|
14
|
+
// ============================================
|
|
15
|
+
|
|
16
|
+
export interface MediaBinProps {
|
|
17
|
+
/** The active MediaProvider (null when no media context is available) */
|
|
18
|
+
mediaProvider: MediaProvider | null;
|
|
19
|
+
/** Whether the editor is in dark mode */
|
|
20
|
+
isDark: boolean;
|
|
21
|
+
/** Incremented externally to signal a re-scan of the media list */
|
|
22
|
+
refreshKey?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ============================================
|
|
26
|
+
// Helpers
|
|
27
|
+
// ============================================
|
|
28
|
+
|
|
29
|
+
function formatSize(bytes: number): string {
|
|
30
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
31
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
32
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function iconForMime(mimeType: string): string {
|
|
36
|
+
if (mimeType.startsWith('image/')) return '\u{1F5BC}';
|
|
37
|
+
if (mimeType.startsWith('audio/')) return '\u{1F50A}';
|
|
38
|
+
if (mimeType.startsWith('video/')) return '\u{1F3AC}';
|
|
39
|
+
if (mimeType.includes('json')) return '{ }';
|
|
40
|
+
if (mimeType.includes('xml') || mimeType.includes('ssml')) return '\u{2329}/\u{232A}';
|
|
41
|
+
return '\u{1F4C4}';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isImageMime(mimeType: string): boolean {
|
|
45
|
+
return mimeType.startsWith('image/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================
|
|
49
|
+
// Component
|
|
50
|
+
// ============================================
|
|
51
|
+
|
|
52
|
+
export function MediaBin({ mediaProvider, isDark, refreshKey }: MediaBinProps) {
|
|
53
|
+
const [entries, setEntries] = useState<MediaEntry[]>([]);
|
|
54
|
+
const [thumbUrls, setThumbUrls] = useState<Record<string, string>>({});
|
|
55
|
+
const [loading, setLoading] = useState(false);
|
|
56
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
57
|
+
|
|
58
|
+
// Scan media entries whenever the provider changes or refreshKey bumps
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!mediaProvider) {
|
|
61
|
+
setEntries([]);
|
|
62
|
+
setThumbUrls({});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let cancelled = false;
|
|
67
|
+
|
|
68
|
+
async function scan() {
|
|
69
|
+
setLoading(true);
|
|
70
|
+
try {
|
|
71
|
+
const list = await mediaProvider!.listMedia();
|
|
72
|
+
if (cancelled) return;
|
|
73
|
+
|
|
74
|
+
list.sort((a, b) => {
|
|
75
|
+
const aImg = isImageMime(a.mimeType) ? 0 : 1;
|
|
76
|
+
const bImg = isImageMime(b.mimeType) ? 0 : 1;
|
|
77
|
+
if (aImg !== bImg) return aImg - bImg;
|
|
78
|
+
return a.name.localeCompare(b.name);
|
|
79
|
+
});
|
|
80
|
+
setEntries(list);
|
|
81
|
+
|
|
82
|
+
const urls: Record<string, string> = {};
|
|
83
|
+
for (const entry of list) {
|
|
84
|
+
if (isImageMime(entry.mimeType)) {
|
|
85
|
+
try {
|
|
86
|
+
urls[entry.name] = await mediaProvider!.resolveUrl(entry.name);
|
|
87
|
+
} catch {
|
|
88
|
+
// skip failed resolve
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!cancelled) setThumbUrls(urls);
|
|
93
|
+
} finally {
|
|
94
|
+
if (!cancelled) setLoading(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
scan();
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
};
|
|
102
|
+
}, [mediaProvider, refreshKey]);
|
|
103
|
+
|
|
104
|
+
// ---- Upload ----
|
|
105
|
+
|
|
106
|
+
const handleUploadClick = useCallback(() => {
|
|
107
|
+
fileInputRef.current?.click();
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const handleFileChange = useCallback(
|
|
111
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
112
|
+
const files = e.target.files;
|
|
113
|
+
if (!files || !mediaProvider) return;
|
|
114
|
+
|
|
115
|
+
setLoading(true);
|
|
116
|
+
try {
|
|
117
|
+
for (let i = 0; i < files.length; i++) {
|
|
118
|
+
const file = files[i];
|
|
119
|
+
const buffer = await file.arrayBuffer();
|
|
120
|
+
const mimeType = file.type || 'application/octet-stream';
|
|
121
|
+
await mediaProvider.addMedia(file.name, buffer, mimeType);
|
|
122
|
+
}
|
|
123
|
+
// Re-scan
|
|
124
|
+
const list = await mediaProvider.listMedia();
|
|
125
|
+
list.sort((a, b) => {
|
|
126
|
+
const aImg = isImageMime(a.mimeType) ? 0 : 1;
|
|
127
|
+
const bImg = isImageMime(b.mimeType) ? 0 : 1;
|
|
128
|
+
if (aImg !== bImg) return aImg - bImg;
|
|
129
|
+
return a.name.localeCompare(b.name);
|
|
130
|
+
});
|
|
131
|
+
setEntries(list);
|
|
132
|
+
|
|
133
|
+
const urls: Record<string, string> = {};
|
|
134
|
+
for (const entry of list) {
|
|
135
|
+
if (isImageMime(entry.mimeType)) {
|
|
136
|
+
try {
|
|
137
|
+
urls[entry.name] = await mediaProvider.resolveUrl(entry.name);
|
|
138
|
+
} catch {
|
|
139
|
+
// skip
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
setThumbUrls(urls);
|
|
144
|
+
} finally {
|
|
145
|
+
setLoading(false);
|
|
146
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
[mediaProvider],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className={`squisq-media-bin${isDark ? ' squisq-media-bin--dark' : ''}`}>
|
|
154
|
+
{/* Header */}
|
|
155
|
+
<div className="squisq-media-bin-header">
|
|
156
|
+
<span className="squisq-media-bin-title">
|
|
157
|
+
Files {entries.length > 0 && `(${entries.length})`}
|
|
158
|
+
</span>
|
|
159
|
+
|
|
160
|
+
<button
|
|
161
|
+
className="squisq-media-bin-upload"
|
|
162
|
+
onClick={handleUploadClick}
|
|
163
|
+
disabled={!mediaProvider || loading}
|
|
164
|
+
title={
|
|
165
|
+
mediaProvider ? 'Upload files' : 'Load a content zip or select a storage slot first'
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
+ Upload
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* File list */}
|
|
173
|
+
<div className="squisq-media-bin-list">
|
|
174
|
+
{!mediaProvider && (
|
|
175
|
+
<div className="squisq-media-bin-empty">
|
|
176
|
+
No media context.
|
|
177
|
+
<br />
|
|
178
|
+
Load a content zip or select a storage slot.
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{mediaProvider && entries.length === 0 && !loading && (
|
|
183
|
+
<div className="squisq-media-bin-empty">No files yet.</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{entries.map((entry) => {
|
|
187
|
+
const thumb = thumbUrls[entry.name];
|
|
188
|
+
const basename = entry.name.includes('/') ? entry.name.split('/').pop()! : entry.name;
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
key={entry.name}
|
|
193
|
+
className="squisq-media-bin-item"
|
|
194
|
+
title={`${entry.name}\n${entry.mimeType}\n${formatSize(entry.size)}`}
|
|
195
|
+
>
|
|
196
|
+
{/* Thumbnail or icon */}
|
|
197
|
+
{thumb ? (
|
|
198
|
+
<img src={thumb} alt={basename} className="squisq-media-bin-thumb" />
|
|
199
|
+
) : (
|
|
200
|
+
<span className="squisq-media-bin-icon">{iconForMime(entry.mimeType)}</span>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{/* Name + size */}
|
|
204
|
+
<div className="squisq-media-bin-meta">
|
|
205
|
+
<div className="squisq-media-bin-name">{basename}</div>
|
|
206
|
+
<div className="squisq-media-bin-size">{formatSize(entry.size)}</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Hidden file input */}
|
|
214
|
+
<input
|
|
215
|
+
ref={fileInputRef}
|
|
216
|
+
type="file"
|
|
217
|
+
multiple
|
|
218
|
+
style={{ display: 'none' }}
|
|
219
|
+
onChange={handleFileChange}
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
package/src/Toolbar.tsx
CHANGED
|
@@ -21,6 +21,10 @@ const VIEWS: { id: EditorView; label: string; shortcut: string }[] = [
|
|
|
21
21
|
export interface ToolbarProps {
|
|
22
22
|
/** Additional class name */
|
|
23
23
|
className?: string;
|
|
24
|
+
/** Whether the Files panel is currently shown */
|
|
25
|
+
showFiles?: boolean;
|
|
26
|
+
/** Toggle the Files panel. When provided, a "Files" button appears in the toolbar. */
|
|
27
|
+
onToggleFiles?: () => void;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
interface ToolbarButton {
|
|
@@ -113,7 +117,7 @@ function isTiptapActive(editor: TiptapEditor, id: string): boolean {
|
|
|
113
117
|
* - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
|
|
114
118
|
* - Raw: appends markdown syntax to the source
|
|
115
119
|
*/
|
|
116
|
-
export function Toolbar({ className }: ToolbarProps) {
|
|
120
|
+
export function Toolbar({ className, showFiles, onToggleFiles }: ToolbarProps) {
|
|
117
121
|
const {
|
|
118
122
|
activeView,
|
|
119
123
|
setActiveView,
|
|
@@ -465,6 +469,22 @@ export function Toolbar({ className }: ToolbarProps) {
|
|
|
465
469
|
)}
|
|
466
470
|
</div>
|
|
467
471
|
)}
|
|
472
|
+
|
|
473
|
+
{/* Spacer pushes right-side buttons to the end */}
|
|
474
|
+
{onToggleFiles && <div style={{ flex: 1 }} />}
|
|
475
|
+
|
|
476
|
+
{/* Files toggle — visible when callback is provided */}
|
|
477
|
+
{onToggleFiles && (
|
|
478
|
+
<button
|
|
479
|
+
className={`squisq-toolbar-button squisq-toolbar-files-toggle${showFiles ? ' squisq-toolbar-button--active' : ''}`}
|
|
480
|
+
onClick={onToggleFiles}
|
|
481
|
+
title={showFiles ? 'Hide Files panel' : 'Show Files panel'}
|
|
482
|
+
aria-pressed={showFiles}
|
|
483
|
+
aria-label="Toggle Files panel"
|
|
484
|
+
>
|
|
485
|
+
{'\u{1F4CE}'}
|
|
486
|
+
</button>
|
|
487
|
+
)}
|
|
468
488
|
</div>
|
|
469
489
|
);
|
|
470
490
|
}
|