@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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFileDrop
|
|
3
|
+
*
|
|
4
|
+
* React hook that manages HTML5 drag-and-drop state for file uploads.
|
|
5
|
+
* Tracks whether files are being dragged over the target element,
|
|
6
|
+
* classifies dragged file types (media vs text), and dispatches
|
|
7
|
+
* drop events to callers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
// ─── File classification ────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
15
|
+
'png',
|
|
16
|
+
'jpg',
|
|
17
|
+
'jpeg',
|
|
18
|
+
'gif',
|
|
19
|
+
'webp',
|
|
20
|
+
'svg',
|
|
21
|
+
'avif',
|
|
22
|
+
'ico',
|
|
23
|
+
'mp4',
|
|
24
|
+
'webm',
|
|
25
|
+
'mov',
|
|
26
|
+
'avi',
|
|
27
|
+
'mp3',
|
|
28
|
+
'wav',
|
|
29
|
+
'ogg',
|
|
30
|
+
'aac',
|
|
31
|
+
'm4a',
|
|
32
|
+
'flac',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const TEXT_EXTENSIONS = new Set(['md', 'txt', 'docx']);
|
|
36
|
+
|
|
37
|
+
export type FileCategory = 'media' | 'text' | 'unknown';
|
|
38
|
+
export type DragContentType = 'media' | 'text' | 'mixed' | null;
|
|
39
|
+
export type DropTarget = 'media' | 'insert' | 'replace';
|
|
40
|
+
|
|
41
|
+
function extensionOf(name: string): string {
|
|
42
|
+
const dot = name.lastIndexOf('.');
|
|
43
|
+
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function classifyFile(file: { name: string; type: string }): FileCategory {
|
|
47
|
+
const ext = extensionOf(file.name);
|
|
48
|
+
if (MEDIA_EXTENSIONS.has(ext)) return 'media';
|
|
49
|
+
if (TEXT_EXTENSIONS.has(ext)) return 'text';
|
|
50
|
+
|
|
51
|
+
// Fallback to MIME type
|
|
52
|
+
if (
|
|
53
|
+
file.type.startsWith('image/') ||
|
|
54
|
+
file.type.startsWith('video/') ||
|
|
55
|
+
file.type.startsWith('audio/')
|
|
56
|
+
) {
|
|
57
|
+
return 'media';
|
|
58
|
+
}
|
|
59
|
+
if (file.type === 'text/plain' || file.type === 'text/markdown') {
|
|
60
|
+
return 'text';
|
|
61
|
+
}
|
|
62
|
+
return 'unknown';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Classify dragged items from a DataTransfer during dragenter/dragover.
|
|
67
|
+
* Browsers restrict full file access during drag — only MIME types are
|
|
68
|
+
* available via DataTransferItem.type.
|
|
69
|
+
*/
|
|
70
|
+
function classifyDataTransferItems(items: DataTransferItemList): DragContentType {
|
|
71
|
+
let hasMedia = false;
|
|
72
|
+
let hasText = false;
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < items.length; i++) {
|
|
75
|
+
const item = items[i];
|
|
76
|
+
if (item.kind !== 'file') continue;
|
|
77
|
+
|
|
78
|
+
const mime = item.type.toLowerCase();
|
|
79
|
+
if (mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')) {
|
|
80
|
+
hasMedia = true;
|
|
81
|
+
} else if (
|
|
82
|
+
mime === 'text/plain' ||
|
|
83
|
+
mime === 'text/markdown' ||
|
|
84
|
+
mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
85
|
+
) {
|
|
86
|
+
hasText = true;
|
|
87
|
+
} else {
|
|
88
|
+
// Unknown MIME — browsers often report '' for many file types during drag.
|
|
89
|
+
// We can't classify, so assume mixed to show all drop zones.
|
|
90
|
+
hasMedia = true;
|
|
91
|
+
hasText = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (hasMedia && hasText) return 'mixed';
|
|
96
|
+
if (hasMedia) return 'media';
|
|
97
|
+
if (hasText) return 'text';
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Hook ────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export interface UseFileDropOptions {
|
|
104
|
+
/** Called when files are dropped on a specific target zone. */
|
|
105
|
+
onDrop: (files: File[], target: DropTarget) => void;
|
|
106
|
+
/** Whether drop is enabled (default: true) */
|
|
107
|
+
enabled?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface UseFileDropResult {
|
|
111
|
+
/** Whether a drag-with-files is currently hovering over the container */
|
|
112
|
+
isDragging: boolean;
|
|
113
|
+
/** Classification of the dragged content */
|
|
114
|
+
dragContentType: DragContentType;
|
|
115
|
+
/** Attach these to the container element */
|
|
116
|
+
containerProps: {
|
|
117
|
+
onDragEnter: (e: React.DragEvent) => void;
|
|
118
|
+
onDragOver: (e: React.DragEvent) => void;
|
|
119
|
+
onDragLeave: (e: React.DragEvent) => void;
|
|
120
|
+
onDrop: (e: React.DragEvent) => void;
|
|
121
|
+
};
|
|
122
|
+
/** Create props for an individual drop zone target */
|
|
123
|
+
zoneProps: (target: DropTarget) => {
|
|
124
|
+
onDragOver: (e: React.DragEvent) => void;
|
|
125
|
+
onDrop: (e: React.DragEvent) => void;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function useFileDrop({ onDrop, enabled = true }: UseFileDropOptions): UseFileDropResult {
|
|
130
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
131
|
+
const [dragContentType, setDragContentType] = useState<DragContentType>(null);
|
|
132
|
+
|
|
133
|
+
// Counter-based tracking: dragenter/dragleave fire on child elements,
|
|
134
|
+
// so we track a count rather than a boolean.
|
|
135
|
+
const dragCounterRef = useRef(0);
|
|
136
|
+
|
|
137
|
+
const handleDragEnter = useCallback(
|
|
138
|
+
(e: React.DragEvent) => {
|
|
139
|
+
if (!enabled) return;
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
dragCounterRef.current++;
|
|
142
|
+
|
|
143
|
+
if (dragCounterRef.current === 1) {
|
|
144
|
+
setIsDragging(true);
|
|
145
|
+
if (e.dataTransfer.items) {
|
|
146
|
+
setDragContentType(classifyDataTransferItems(e.dataTransfer.items));
|
|
147
|
+
} else {
|
|
148
|
+
// Fallback: can't classify, show all zones
|
|
149
|
+
setDragContentType('mixed');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
[enabled],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const handleDragOver = useCallback(
|
|
157
|
+
(e: React.DragEvent) => {
|
|
158
|
+
if (!enabled) return;
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
// Required to allow drop
|
|
161
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
162
|
+
},
|
|
163
|
+
[enabled],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const handleDragLeave = useCallback(
|
|
167
|
+
(e: React.DragEvent) => {
|
|
168
|
+
if (!enabled) return;
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
|
171
|
+
|
|
172
|
+
if (dragCounterRef.current === 0) {
|
|
173
|
+
setIsDragging(false);
|
|
174
|
+
setDragContentType(null);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
[enabled],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const handleDrop = useCallback(
|
|
181
|
+
(e: React.DragEvent) => {
|
|
182
|
+
if (!enabled) return;
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
dragCounterRef.current = 0;
|
|
185
|
+
setIsDragging(false);
|
|
186
|
+
setDragContentType(null);
|
|
187
|
+
// Actual file handling is done by zone-specific onDrop
|
|
188
|
+
},
|
|
189
|
+
[enabled],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const zoneProps = useCallback(
|
|
193
|
+
(target: DropTarget) => ({
|
|
194
|
+
onDragOver: (e: React.DragEvent) => {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
e.stopPropagation();
|
|
197
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
198
|
+
},
|
|
199
|
+
onDrop: (e: React.DragEvent) => {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
e.stopPropagation();
|
|
202
|
+
dragCounterRef.current = 0;
|
|
203
|
+
setIsDragging(false);
|
|
204
|
+
setDragContentType(null);
|
|
205
|
+
|
|
206
|
+
const files = Array.from(e.dataTransfer.files);
|
|
207
|
+
if (files.length > 0) {
|
|
208
|
+
onDrop(files, target);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
[onDrop],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
isDragging,
|
|
217
|
+
dragContentType,
|
|
218
|
+
containerProps: {
|
|
219
|
+
onDragEnter: handleDragEnter,
|
|
220
|
+
onDragOver: handleDragOver,
|
|
221
|
+
onDragLeave: handleDragLeave,
|
|
222
|
+
onDrop: handleDrop,
|
|
223
|
+
},
|
|
224
|
+
zoneProps,
|
|
225
|
+
};
|
|
226
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -48,9 +48,32 @@ export type { ViewSwitcherProps } from './ViewSwitcher.js';
|
|
|
48
48
|
export { Toolbar } from './Toolbar.js';
|
|
49
49
|
export type { ToolbarProps } from './Toolbar.js';
|
|
50
50
|
|
|
51
|
+
export { MediaBin } from './MediaBin.js';
|
|
52
|
+
export type { MediaBinProps } from './MediaBin.js';
|
|
53
|
+
|
|
51
54
|
export { StatusBar } from './StatusBar.js';
|
|
52
55
|
export type { StatusBarProps } from './StatusBar.js';
|
|
53
56
|
|
|
57
|
+
// Drag-and-drop
|
|
58
|
+
export { DropZoneOverlay } from './DropZoneOverlay.js';
|
|
59
|
+
export type { DropZoneOverlayProps } from './DropZoneOverlay.js';
|
|
60
|
+
|
|
61
|
+
export { useFileDrop, classifyFile } from './hooks/useFileDrop.js';
|
|
62
|
+
export type {
|
|
63
|
+
FileCategory,
|
|
64
|
+
DragContentType,
|
|
65
|
+
DropTarget,
|
|
66
|
+
UseFileDropOptions,
|
|
67
|
+
UseFileDropResult,
|
|
68
|
+
} from './hooks/useFileDrop.js';
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
partitionFiles,
|
|
72
|
+
processMediaFiles,
|
|
73
|
+
processTextFile,
|
|
74
|
+
processTextFiles,
|
|
75
|
+
} from './utils/dropUtils.js';
|
|
76
|
+
|
|
54
77
|
// Bridge utilities
|
|
55
78
|
export { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge.js';
|
|
56
79
|
|
package/src/styles/editor.css
CHANGED
|
@@ -613,3 +613,321 @@
|
|
|
613
613
|
padding-top: 2px;
|
|
614
614
|
}
|
|
615
615
|
}
|
|
616
|
+
|
|
617
|
+
/* ─── Media Bin ──────────────────────────────────────── */
|
|
618
|
+
|
|
619
|
+
.squisq-media-bin {
|
|
620
|
+
width: 220px;
|
|
621
|
+
border-left: 1px solid #e5e7eb;
|
|
622
|
+
background: #f9fafb;
|
|
623
|
+
display: flex;
|
|
624
|
+
flex-direction: column;
|
|
625
|
+
flex-shrink: 0;
|
|
626
|
+
overflow: hidden;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.squisq-media-bin-header {
|
|
630
|
+
display: flex;
|
|
631
|
+
align-items: center;
|
|
632
|
+
justify-content: space-between;
|
|
633
|
+
padding: 8px 10px;
|
|
634
|
+
border-bottom: 1px solid #e5e7eb;
|
|
635
|
+
flex-shrink: 0;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.squisq-media-bin-title {
|
|
639
|
+
font-size: 12px;
|
|
640
|
+
font-weight: 600;
|
|
641
|
+
color: #374151;
|
|
642
|
+
text-transform: uppercase;
|
|
643
|
+
letter-spacing: 0.05em;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.squisq-media-bin-upload {
|
|
647
|
+
font-size: 11px;
|
|
648
|
+
padding: 2px 8px;
|
|
649
|
+
cursor: pointer;
|
|
650
|
+
background: #e5e7eb;
|
|
651
|
+
color: #374151;
|
|
652
|
+
border: 1px solid #d1d5db;
|
|
653
|
+
border-radius: 4px;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.squisq-media-bin-upload:hover {
|
|
657
|
+
background: #d1d5db;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.squisq-media-bin-upload:disabled {
|
|
661
|
+
cursor: default;
|
|
662
|
+
opacity: 0.5;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.squisq-media-bin-list {
|
|
666
|
+
flex: 1;
|
|
667
|
+
overflow: auto;
|
|
668
|
+
padding: 4px 0;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.squisq-media-bin-empty {
|
|
672
|
+
padding: 20px 10px;
|
|
673
|
+
font-size: 12px;
|
|
674
|
+
color: #9ca3af;
|
|
675
|
+
text-align: center;
|
|
676
|
+
line-height: 1.5;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.squisq-media-bin-item {
|
|
680
|
+
display: flex;
|
|
681
|
+
align-items: center;
|
|
682
|
+
gap: 6px;
|
|
683
|
+
padding: 4px 10px;
|
|
684
|
+
cursor: default;
|
|
685
|
+
font-size: 11px;
|
|
686
|
+
color: #374151;
|
|
687
|
+
border-bottom: 1px solid #f3f4f6;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.squisq-media-bin-thumb {
|
|
691
|
+
width: 32px;
|
|
692
|
+
height: 32px;
|
|
693
|
+
object-fit: cover;
|
|
694
|
+
border-radius: 2px;
|
|
695
|
+
flex-shrink: 0;
|
|
696
|
+
background: #e5e7eb;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.squisq-media-bin-icon {
|
|
700
|
+
width: 32px;
|
|
701
|
+
height: 32px;
|
|
702
|
+
display: flex;
|
|
703
|
+
align-items: center;
|
|
704
|
+
justify-content: center;
|
|
705
|
+
font-size: 16px;
|
|
706
|
+
flex-shrink: 0;
|
|
707
|
+
background: #f3f4f6;
|
|
708
|
+
border-radius: 2px;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.squisq-media-bin-meta {
|
|
712
|
+
min-width: 0;
|
|
713
|
+
flex: 1;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.squisq-media-bin-name {
|
|
717
|
+
overflow: hidden;
|
|
718
|
+
text-overflow: ellipsis;
|
|
719
|
+
white-space: nowrap;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.squisq-media-bin-size {
|
|
723
|
+
font-size: 10px;
|
|
724
|
+
color: #9ca3af;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/* ─── Media Bin (dark) ───────────────────────────────── */
|
|
728
|
+
|
|
729
|
+
.squisq-media-bin--dark,
|
|
730
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin {
|
|
731
|
+
background: #1e293b;
|
|
732
|
+
border-left-color: #374151;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.squisq-media-bin--dark .squisq-media-bin-header,
|
|
736
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-header {
|
|
737
|
+
border-bottom-color: #374151;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.squisq-media-bin--dark .squisq-media-bin-title,
|
|
741
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-title {
|
|
742
|
+
color: #d1d5db;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.squisq-media-bin--dark .squisq-media-bin-upload,
|
|
746
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-upload {
|
|
747
|
+
background: #374151;
|
|
748
|
+
color: #d1d5db;
|
|
749
|
+
border-color: #4b5563;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.squisq-media-bin--dark .squisq-media-bin-upload:hover,
|
|
753
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-upload:hover {
|
|
754
|
+
background: #4b5563;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
.squisq-media-bin--dark .squisq-media-bin-item,
|
|
758
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-item {
|
|
759
|
+
color: #d1d5db;
|
|
760
|
+
border-bottom-color: #2d3748;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.squisq-media-bin--dark .squisq-media-bin-icon,
|
|
764
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-icon {
|
|
765
|
+
background: #374151;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
.squisq-media-bin--dark .squisq-media-bin-thumb,
|
|
769
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-thumb {
|
|
770
|
+
background: #374151;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.squisq-media-bin--dark .squisq-media-bin-size,
|
|
774
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-size {
|
|
775
|
+
color: #6b7280;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.squisq-media-bin--dark .squisq-media-bin-empty,
|
|
779
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-media-bin .squisq-media-bin-empty {
|
|
780
|
+
color: #6b7280;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* ============================================
|
|
784
|
+
Drop Zone Overlay
|
|
785
|
+
============================================ */
|
|
786
|
+
|
|
787
|
+
.squisq-drop-overlay {
|
|
788
|
+
position: absolute;
|
|
789
|
+
inset: 0;
|
|
790
|
+
z-index: 50;
|
|
791
|
+
display: flex;
|
|
792
|
+
align-items: center;
|
|
793
|
+
justify-content: center;
|
|
794
|
+
background: rgba(255, 255, 255, 0.85);
|
|
795
|
+
backdrop-filter: blur(4px);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
.squisq-drop-overlay-inner {
|
|
799
|
+
display: flex;
|
|
800
|
+
gap: 24px;
|
|
801
|
+
padding: 32px;
|
|
802
|
+
max-width: 90%;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.squisq-drop-zone {
|
|
806
|
+
display: flex;
|
|
807
|
+
flex-direction: column;
|
|
808
|
+
align-items: center;
|
|
809
|
+
justify-content: center;
|
|
810
|
+
gap: 8px;
|
|
811
|
+
width: 200px;
|
|
812
|
+
min-height: 180px;
|
|
813
|
+
padding: 24px 16px;
|
|
814
|
+
border: 2px dashed #94a3b8;
|
|
815
|
+
border-radius: 12px;
|
|
816
|
+
background: rgba(255, 255, 255, 0.9);
|
|
817
|
+
cursor: default;
|
|
818
|
+
transition:
|
|
819
|
+
border-color 0.15s,
|
|
820
|
+
background-color 0.15s,
|
|
821
|
+
transform 0.15s;
|
|
822
|
+
text-align: center;
|
|
823
|
+
user-select: none;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
.squisq-drop-zone--active {
|
|
827
|
+
transform: scale(1.04);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
.squisq-drop-zone--disabled {
|
|
831
|
+
opacity: 0.45;
|
|
832
|
+
cursor: not-allowed;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/* Variant colors */
|
|
836
|
+
.squisq-drop-zone--media {
|
|
837
|
+
border-color: #60a5fa;
|
|
838
|
+
}
|
|
839
|
+
.squisq-drop-zone--media.squisq-drop-zone--active {
|
|
840
|
+
border-color: #3b82f6;
|
|
841
|
+
background: rgba(219, 234, 254, 0.9);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
.squisq-drop-zone--insert {
|
|
845
|
+
border-color: #34d399;
|
|
846
|
+
}
|
|
847
|
+
.squisq-drop-zone--insert.squisq-drop-zone--active {
|
|
848
|
+
border-color: #10b981;
|
|
849
|
+
background: rgba(209, 250, 229, 0.9);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.squisq-drop-zone--replace {
|
|
853
|
+
border-color: #f97316;
|
|
854
|
+
}
|
|
855
|
+
.squisq-drop-zone--replace.squisq-drop-zone--active {
|
|
856
|
+
border-color: #ea580c;
|
|
857
|
+
background: rgba(255, 237, 213, 0.9);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/* Zone content */
|
|
861
|
+
.squisq-drop-zone-icon {
|
|
862
|
+
font-size: 32px;
|
|
863
|
+
line-height: 1;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
.squisq-drop-zone-label {
|
|
867
|
+
font-size: 16px;
|
|
868
|
+
font-weight: 600;
|
|
869
|
+
color: #1e293b;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.squisq-drop-zone-desc {
|
|
873
|
+
font-size: 12px;
|
|
874
|
+
color: #64748b;
|
|
875
|
+
line-height: 1.4;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/* Dark mode */
|
|
879
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-overlay {
|
|
880
|
+
background: rgba(17, 24, 39, 0.88);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone {
|
|
884
|
+
border-color: #4b5563;
|
|
885
|
+
background: rgba(31, 41, 55, 0.9);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone--media {
|
|
889
|
+
border-color: #3b82f6;
|
|
890
|
+
}
|
|
891
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone--media.squisq-drop-zone--active {
|
|
892
|
+
border-color: #60a5fa;
|
|
893
|
+
background: rgba(30, 58, 138, 0.6);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone--insert {
|
|
897
|
+
border-color: #10b981;
|
|
898
|
+
}
|
|
899
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone--insert.squisq-drop-zone--active {
|
|
900
|
+
border-color: #34d399;
|
|
901
|
+
background: rgba(6, 78, 59, 0.6);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone--replace {
|
|
905
|
+
border-color: #ea580c;
|
|
906
|
+
}
|
|
907
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone--replace.squisq-drop-zone--active {
|
|
908
|
+
border-color: #f97316;
|
|
909
|
+
background: rgba(124, 45, 18, 0.6);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone-label {
|
|
913
|
+
color: #e2e8f0;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.squisq-editor-shell[data-theme='dark'] .squisq-drop-zone-desc {
|
|
917
|
+
color: #94a3b8;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/* Responsive */
|
|
921
|
+
@media (max-width: 640px) {
|
|
922
|
+
.squisq-drop-overlay-inner {
|
|
923
|
+
flex-direction: column;
|
|
924
|
+
gap: 16px;
|
|
925
|
+
align-items: center;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
.squisq-drop-zone {
|
|
929
|
+
width: 100%;
|
|
930
|
+
max-width: 240px;
|
|
931
|
+
min-height: 120px;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop Utilities
|
|
3
|
+
*
|
|
4
|
+
* File processing pipeline for dropped files. Classifies files by type,
|
|
5
|
+
* processes media files into a MediaProvider, and converts text files
|
|
6
|
+
* (.md, .txt, .docx) to markdown strings.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
10
|
+
import { stringifyMarkdown } from '@bendyline/squisq/markdown';
|
|
11
|
+
import { docxToMarkdownDoc } from '@bendyline/squisq-formats/docx';
|
|
12
|
+
import { classifyFile, type FileCategory } from '../hooks/useFileDrop';
|
|
13
|
+
|
|
14
|
+
export type { FileCategory };
|
|
15
|
+
export { classifyFile };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Partition an array of files into media and text categories.
|
|
19
|
+
* Files with unknown type are skipped.
|
|
20
|
+
*/
|
|
21
|
+
export function partitionFiles(files: File[]): { media: File[]; text: File[] } {
|
|
22
|
+
const media: File[] = [];
|
|
23
|
+
const text: File[] = [];
|
|
24
|
+
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const cat = classifyFile(file);
|
|
27
|
+
if (cat === 'media') media.push(file);
|
|
28
|
+
else if (cat === 'text') text.push(file);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { media, text };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Add media files to a MediaProvider. Returns the relative paths
|
|
36
|
+
* assigned by the provider.
|
|
37
|
+
*/
|
|
38
|
+
export async function processMediaFiles(
|
|
39
|
+
files: File[],
|
|
40
|
+
mediaProvider: MediaProvider,
|
|
41
|
+
): Promise<string[]> {
|
|
42
|
+
const paths: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const buffer = await file.arrayBuffer();
|
|
46
|
+
const mimeType = file.type || 'application/octet-stream';
|
|
47
|
+
const path = await mediaProvider.addMedia(file.name, buffer, mimeType);
|
|
48
|
+
paths.push(path);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return paths;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read a text-content file and return its content as a markdown string.
|
|
56
|
+
*
|
|
57
|
+
* - `.md` and `.txt` files are read as UTF-8 text directly
|
|
58
|
+
* - `.docx` files are converted to markdown via `@bendyline/squisq-formats/docx`
|
|
59
|
+
*/
|
|
60
|
+
export async function processTextFile(file: File): Promise<string> {
|
|
61
|
+
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
|
62
|
+
|
|
63
|
+
if (ext === 'md' || ext === 'txt') {
|
|
64
|
+
return await file.text();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (ext === 'docx') {
|
|
68
|
+
const buffer = await file.arrayBuffer();
|
|
69
|
+
const markdownDoc = await docxToMarkdownDoc(buffer);
|
|
70
|
+
return stringifyMarkdown(markdownDoc);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return await file.text();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Process multiple text files and concatenate their content.
|
|
78
|
+
*/
|
|
79
|
+
export async function processTextFiles(files: File[]): Promise<string> {
|
|
80
|
+
const results: string[] = [];
|
|
81
|
+
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
const content = await processTextFile(file);
|
|
84
|
+
results.push(content);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return results.join('\n\n');
|
|
88
|
+
}
|