@bendyline/squisq-editor-react 1.0.0 → 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.
Files changed (70) 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 +79 -0
  6. package/dist/EditorContext.d.ts.map +1 -0
  7. package/dist/EditorContext.js +204 -0
  8. package/dist/EditorContext.js.map +1 -0
  9. package/dist/EditorShell.d.ts +39 -0
  10. package/dist/EditorShell.d.ts.map +1 -0
  11. package/dist/EditorShell.js +104 -0
  12. package/dist/EditorShell.js.map +1 -0
  13. package/dist/MediaBin.d.ts +18 -0
  14. package/dist/MediaBin.d.ts.map +1 -0
  15. package/dist/MediaBin.js +141 -0
  16. package/dist/MediaBin.js.map +1 -0
  17. package/dist/PreviewPanel.d.ts +33 -0
  18. package/dist/PreviewPanel.d.ts.map +1 -0
  19. package/dist/PreviewPanel.js +385 -0
  20. package/dist/PreviewPanel.js.map +1 -0
  21. package/dist/RawEditor.d.ts +25 -0
  22. package/dist/RawEditor.d.ts.map +1 -0
  23. package/dist/RawEditor.js +100 -0
  24. package/dist/RawEditor.js.map +1 -0
  25. package/dist/StatusBar.d.ts +15 -0
  26. package/dist/StatusBar.d.ts.map +1 -0
  27. package/dist/StatusBar.js +24 -0
  28. package/dist/StatusBar.js.map +1 -0
  29. package/dist/TemplateAnnotation.d.ts +20 -0
  30. package/dist/TemplateAnnotation.d.ts.map +1 -0
  31. package/dist/TemplateAnnotation.js +69 -0
  32. package/dist/TemplateAnnotation.js.map +1 -0
  33. package/dist/Toolbar.d.ts +23 -0
  34. package/dist/Toolbar.d.ts.map +1 -0
  35. package/dist/Toolbar.js +350 -0
  36. package/dist/Toolbar.js.map +1 -0
  37. package/dist/ViewSwitcher.d.ts +14 -0
  38. package/dist/ViewSwitcher.d.ts.map +1 -0
  39. package/dist/ViewSwitcher.js +20 -0
  40. package/dist/ViewSwitcher.js.map +1 -0
  41. package/dist/WysiwygEditor.d.ts +28 -0
  42. package/dist/WysiwygEditor.d.ts.map +1 -0
  43. package/dist/WysiwygEditor.js +111 -0
  44. package/dist/WysiwygEditor.js.map +1 -0
  45. package/dist/hooks/useFileDrop.d.ts +41 -0
  46. package/dist/hooks/useFileDrop.d.ts.map +1 -0
  47. package/dist/hooks/useFileDrop.js +167 -0
  48. package/dist/hooks/useFileDrop.js.map +1 -0
  49. package/dist/index.d.ts +43 -268
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +39 -3832
  52. package/dist/index.js.map +1 -1
  53. package/dist/tiptapBridge.d.ts +24 -0
  54. package/dist/tiptapBridge.d.ts.map +1 -0
  55. package/dist/tiptapBridge.js +358 -0
  56. package/dist/tiptapBridge.js.map +1 -0
  57. package/dist/utils/dropUtils.d.ts +36 -0
  58. package/dist/utils/dropUtils.d.ts.map +1 -0
  59. package/dist/utils/dropUtils.js +71 -0
  60. package/dist/utils/dropUtils.js.map +1 -0
  61. package/package.json +5 -5
  62. package/src/DropZoneOverlay.tsx +137 -0
  63. package/src/EditorContext.tsx +56 -0
  64. package/src/EditorShell.tsx +102 -8
  65. package/src/MediaBin.tsx +223 -0
  66. package/src/Toolbar.tsx +21 -1
  67. package/src/hooks/useFileDrop.ts +226 -0
  68. package/src/index.ts +23 -0
  69. package/src/styles/editor.css +318 -0
  70. 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
+ }
@@ -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
 
@@ -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({ basePath, onChange, className, height }: EditorShellInnerProps) {
78
- const { activeView, markdownSource, theme } = useEditorContext();
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
- {activeView === 'raw' && <RawEditor theme={theme === 'dark' ? 'vs-dark' : 'vs'} />}
131
- {activeView === 'wysiwyg' && <WysiwygEditor />}
132
- {activeView === 'preview' && <PreviewPanel basePath={basePath} />}
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 */}
@@ -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
  }