@0m0g1/griot 0.1.6 → 0.1.8
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/package.json
CHANGED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// ─── DropHandler.js ───────────────────────────────────────────────────────────
|
|
2
|
+
// Drag-and-drop file handler for the Griot editor.
|
|
3
|
+
//
|
|
4
|
+
// Attaches to the editor container and intercepts file drops. Shows a visual
|
|
5
|
+
// insertion indicator between blocks while dragging, then uploads the files
|
|
6
|
+
// and inserts the appropriate block type(s) at the drop position.
|
|
7
|
+
//
|
|
8
|
+
// Drop rules:
|
|
9
|
+
// Multiple images → one gallery block
|
|
10
|
+
// Single image → image block
|
|
11
|
+
// Video file / URL → video block
|
|
12
|
+
// Audio file / URL → audio block
|
|
13
|
+
// Drop onto image block → convert to gallery (or append to existing gallery)
|
|
14
|
+
// Drop onto gallery block → append images to that gallery
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// const handler = new DropHandler(editorContainerEl, {
|
|
18
|
+
// getDoc() // returns the current document
|
|
19
|
+
// onCommit(doc) // called with the updated document
|
|
20
|
+
// onUpload(file) // async fn → { url, src?, alt_text?, caption? }
|
|
21
|
+
// // falls back to uploadUrl if not provided
|
|
22
|
+
// uploadUrl // POST endpoint if onUpload is not provided
|
|
23
|
+
// // default: '/api/upload/insight-media'
|
|
24
|
+
// });
|
|
25
|
+
// handler.destroy();
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
import { createBlock } from '../core/Block.js';
|
|
29
|
+
import { insertBlockAfter, insertBlockBefore,
|
|
30
|
+
updateBlock, getBlock } from '../core/Document.js';
|
|
31
|
+
|
|
32
|
+
const UPLOAD_URL_DEFAULT = '/api/upload/insight-media';
|
|
33
|
+
|
|
34
|
+
// Accepted MIME type groups
|
|
35
|
+
const MIME = {
|
|
36
|
+
image: /^image\//,
|
|
37
|
+
video: /^video\//,
|
|
38
|
+
audio: /^audio\//,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class DropHandler {
|
|
42
|
+
/**
|
|
43
|
+
* @param {HTMLElement} container — the .griot-editor element
|
|
44
|
+
* @param {{
|
|
45
|
+
* getDoc: () => object,
|
|
46
|
+
* onCommit: (doc: object) => void,
|
|
47
|
+
* onUpload?: (file: File) => Promise<object>,
|
|
48
|
+
* uploadUrl?: string,
|
|
49
|
+
* }} options
|
|
50
|
+
*/
|
|
51
|
+
constructor(container, options = {}) {
|
|
52
|
+
this._container = container;
|
|
53
|
+
this._opts = options;
|
|
54
|
+
this._indicator = null; // the visual drop-line element
|
|
55
|
+
this._targetInfo = null; // { blockId, position: 'before'|'after' } | { blockId, position: 'onto' }
|
|
56
|
+
this._dragDepth = 0; // tracks nested dragenter/dragleave pairs
|
|
57
|
+
|
|
58
|
+
this._onDragEnter = this._onDragEnter.bind(this);
|
|
59
|
+
this._onDragOver = this._onDragOver.bind(this);
|
|
60
|
+
this._onDragLeave = this._onDragLeave.bind(this);
|
|
61
|
+
this._onDrop = this._onDrop.bind(this);
|
|
62
|
+
|
|
63
|
+
container.addEventListener('dragenter', this._onDragEnter);
|
|
64
|
+
container.addEventListener('dragover', this._onDragOver);
|
|
65
|
+
container.addEventListener('dragleave', this._onDragLeave);
|
|
66
|
+
container.addEventListener('drop', this._onDrop);
|
|
67
|
+
|
|
68
|
+
_injectStyles();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
destroy() {
|
|
72
|
+
this._container.removeEventListener('dragenter', this._onDragEnter);
|
|
73
|
+
this._container.removeEventListener('dragover', this._onDragOver);
|
|
74
|
+
this._container.removeEventListener('dragleave', this._onDragLeave);
|
|
75
|
+
this._container.removeEventListener('drop', this._onDrop);
|
|
76
|
+
this._removeIndicator();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Drag enter / leave / over ─────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
_onDragEnter(e) {
|
|
82
|
+
if (!_hasFiles(e)) return;
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
this._dragDepth++;
|
|
85
|
+
if (this._dragDepth === 1) this._buildIndicator();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_onDragOver(e) {
|
|
89
|
+
if (!_hasFiles(e)) return;
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
92
|
+
this._updateIndicator(e.clientY);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_onDragLeave(e) {
|
|
96
|
+
if (!_hasFiles(e)) return;
|
|
97
|
+
this._dragDepth--;
|
|
98
|
+
if (this._dragDepth <= 0) {
|
|
99
|
+
this._dragDepth = 0;
|
|
100
|
+
this._removeIndicator();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Drop ──────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async _onDrop(e) {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
this._dragDepth = 0;
|
|
109
|
+
this._removeIndicator();
|
|
110
|
+
|
|
111
|
+
const files = _extractFiles(e);
|
|
112
|
+
if (!files.length) return;
|
|
113
|
+
|
|
114
|
+
// Partition by media type; ignore unsupported types silently
|
|
115
|
+
const images = files.filter(f => MIME.image.test(f.type));
|
|
116
|
+
const videos = files.filter(f => MIME.video.test(f.type));
|
|
117
|
+
const audios = files.filter(f => MIME.audio.test(f.type));
|
|
118
|
+
|
|
119
|
+
// Upload all files in parallel (images as a batch, av one at a time)
|
|
120
|
+
const [imageResults, videoResults, audioResults] = await Promise.all([
|
|
121
|
+
images.length ? this._uploadBatch(images) : Promise.resolve([]),
|
|
122
|
+
videos.length ? this._uploadBatch(videos) : Promise.resolve([]),
|
|
123
|
+
audios.length ? this._uploadBatch(audios) : Promise.resolve([]),
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const target = this._targetInfo;
|
|
127
|
+
let doc = this._opts.getDoc();
|
|
128
|
+
|
|
129
|
+
// ── Drop onto an existing image or gallery block ────────────────────────
|
|
130
|
+
if (target?.position === 'onto' && imageResults.length) {
|
|
131
|
+
const block = getBlock(doc, target.blockId);
|
|
132
|
+
if (block?.type === 'image' && block.meta?.src) {
|
|
133
|
+
// Promote the existing image + new images into a gallery
|
|
134
|
+
const existingItem = {
|
|
135
|
+
src: block.meta.src,
|
|
136
|
+
url: block.meta.src,
|
|
137
|
+
alt: block.meta.alt ?? '',
|
|
138
|
+
caption: block.meta.caption ?? '',
|
|
139
|
+
};
|
|
140
|
+
const newItems = [existingItem, ...imageResults];
|
|
141
|
+
doc = updateBlock(doc, target.blockId, {
|
|
142
|
+
type: 'gallery',
|
|
143
|
+
meta: { items: newItems, layout: 'grid' },
|
|
144
|
+
});
|
|
145
|
+
this._opts.onCommit(doc);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (block?.type === 'gallery') {
|
|
149
|
+
const existing = Array.isArray(block.meta?.items) ? block.meta.items : [];
|
|
150
|
+
doc = updateBlock(doc, target.blockId, {
|
|
151
|
+
meta: { items: [...existing, ...imageResults] },
|
|
152
|
+
});
|
|
153
|
+
this._opts.onCommit(doc);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Insert new blocks at drop position ─────────────────────────────────
|
|
159
|
+
// Build a list of blocks to insert in order: images first, then video, then audio
|
|
160
|
+
const newBlocks = [];
|
|
161
|
+
|
|
162
|
+
if (imageResults.length === 1) {
|
|
163
|
+
newBlocks.push(createBlock('image', {
|
|
164
|
+
meta: {
|
|
165
|
+
src: imageResults[0].url ?? imageResults[0].src ?? '',
|
|
166
|
+
alt: imageResults[0].alt_text ?? '',
|
|
167
|
+
caption: imageResults[0].caption ?? '',
|
|
168
|
+
width: 'full',
|
|
169
|
+
},
|
|
170
|
+
}));
|
|
171
|
+
} else if (imageResults.length > 1) {
|
|
172
|
+
newBlocks.push(createBlock('gallery', {
|
|
173
|
+
meta: {
|
|
174
|
+
items: imageResults.map(r => ({
|
|
175
|
+
src: r.url ?? r.src ?? '',
|
|
176
|
+
url: r.url ?? r.src ?? '',
|
|
177
|
+
alt: r.alt_text ?? '',
|
|
178
|
+
caption: r.caption ?? '',
|
|
179
|
+
})),
|
|
180
|
+
layout: 'grid',
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const r of videoResults) {
|
|
186
|
+
newBlocks.push(createBlock('video', {
|
|
187
|
+
meta: { src: r.url ?? r.src ?? '', caption: r.caption ?? '' },
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const r of audioResults) {
|
|
192
|
+
newBlocks.push(createBlock('audio', {
|
|
193
|
+
meta: { src: r.url ?? r.src ?? '', caption: r.caption ?? '' },
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!newBlocks.length) return;
|
|
198
|
+
|
|
199
|
+
// Insert at target position
|
|
200
|
+
if (!target) {
|
|
201
|
+
// No target resolved — append after last block
|
|
202
|
+
const lastId = doc.blocks[doc.blocks.length - 1]?.id;
|
|
203
|
+
for (const nb of newBlocks) {
|
|
204
|
+
doc = lastId ? insertBlockAfter(doc, lastId, nb) : doc;
|
|
205
|
+
}
|
|
206
|
+
} else if (target.position === 'before') {
|
|
207
|
+
// Insert before target block (reversed so order is preserved)
|
|
208
|
+
for (const nb of [...newBlocks].reverse()) {
|
|
209
|
+
doc = insertBlockBefore(doc, target.blockId, nb);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
// 'after' or 'onto' fallback
|
|
213
|
+
let anchorId = target.blockId;
|
|
214
|
+
for (const nb of newBlocks) {
|
|
215
|
+
doc = insertBlockAfter(doc, anchorId, nb);
|
|
216
|
+
anchorId = nb.id;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this._opts.onCommit(doc);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Upload helpers ────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
async _uploadBatch(files) {
|
|
226
|
+
if (!files.length) return [];
|
|
227
|
+
|
|
228
|
+
// Use provided onUpload if available
|
|
229
|
+
if (typeof this._opts.onUpload === 'function') {
|
|
230
|
+
const results = await Promise.allSettled(files.map(f => this._opts.onUpload(f)));
|
|
231
|
+
return results
|
|
232
|
+
.filter(r => r.status === 'fulfilled' && r.value)
|
|
233
|
+
.map(r => r.value);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Otherwise POST to uploadUrl
|
|
237
|
+
const url = this._opts.uploadUrl ?? UPLOAD_URL_DEFAULT;
|
|
238
|
+
const fd = new FormData();
|
|
239
|
+
files.forEach(f => fd.append('file', f));
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetch(url, { method: 'POST', body: fd });
|
|
243
|
+
const data = await res.json();
|
|
244
|
+
if (!res.ok) throw new Error(data?.error ?? 'Upload failed');
|
|
245
|
+
return (data.files ?? []).filter(f => !f.error);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error('[DropHandler] upload failed:', err);
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Indicator DOM ─────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
_buildIndicator() {
|
|
255
|
+
if (this._indicator) return;
|
|
256
|
+
const el = document.createElement('div');
|
|
257
|
+
el.className = 'griot-drop-indicator';
|
|
258
|
+
this._container.appendChild(el);
|
|
259
|
+
this._indicator = el;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_removeIndicator() {
|
|
263
|
+
this._indicator?.remove();
|
|
264
|
+
this._indicator = null;
|
|
265
|
+
this._targetInfo = null;
|
|
266
|
+
|
|
267
|
+
// Remove 'onto' highlight from any block
|
|
268
|
+
this._container.querySelectorAll('.griot-drop-onto')
|
|
269
|
+
.forEach(el => el.classList.remove('griot-drop-onto'));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Given the current mouse Y coordinate, find the nearest block boundary
|
|
274
|
+
* and position the visual drop indicator there.
|
|
275
|
+
*/
|
|
276
|
+
_updateIndicator(clientY) {
|
|
277
|
+
const blockEls = [
|
|
278
|
+
...this._container.querySelectorAll('[data-block-id]'),
|
|
279
|
+
];
|
|
280
|
+
if (!blockEls.length) return;
|
|
281
|
+
|
|
282
|
+
// Remove previous 'onto' highlight
|
|
283
|
+
blockEls.forEach(el => el.classList.remove('griot-drop-onto'));
|
|
284
|
+
|
|
285
|
+
let best = null; // { el, position, dist }
|
|
286
|
+
const scrollTop = this._container.scrollTop ?? 0;
|
|
287
|
+
|
|
288
|
+
for (const el of blockEls) {
|
|
289
|
+
const rect = el.getBoundingClientRect();
|
|
290
|
+
const midY = rect.top + rect.height / 2;
|
|
291
|
+
const isImage = el.dataset.blockType === 'image' || el.dataset.blockType === 'gallery';
|
|
292
|
+
|
|
293
|
+
// If the cursor is squarely over an image/gallery block (within its rect)
|
|
294
|
+
// AND we're dragging images, offer an 'onto' drop
|
|
295
|
+
if (isImage && clientY >= rect.top && clientY <= rect.bottom) {
|
|
296
|
+
// Only count as 'onto' if dragging only images (not mixed with video/audio)
|
|
297
|
+
best = { el, position: 'onto', dist: 0 };
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const distToTop = Math.abs(clientY - rect.top);
|
|
302
|
+
const distToBot = Math.abs(clientY - rect.bottom);
|
|
303
|
+
|
|
304
|
+
if (!best || distToTop < best.dist) best = { el, position: 'before', dist: distToTop };
|
|
305
|
+
if (!best || distToBot < best.dist) best = { el, position: 'after', dist: distToBot };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!best) return;
|
|
309
|
+
|
|
310
|
+
this._targetInfo = { blockId: best.el.dataset.blockId, position: best.position };
|
|
311
|
+
|
|
312
|
+
if (best.position === 'onto') {
|
|
313
|
+
// Show highlight ring on the target block instead of a line
|
|
314
|
+
best.el.classList.add('griot-drop-onto');
|
|
315
|
+
if (this._indicator) this._indicator.style.display = 'none';
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (this._indicator) this._indicator.style.display = '';
|
|
320
|
+
|
|
321
|
+
// Position the indicator line
|
|
322
|
+
const rect = best.el.getBoundingClientRect();
|
|
323
|
+
const containerR = this._container.getBoundingClientRect();
|
|
324
|
+
const y = (best.position === 'before' ? rect.top : rect.bottom)
|
|
325
|
+
- containerR.top + this._container.scrollTop;
|
|
326
|
+
|
|
327
|
+
if (this._indicator) {
|
|
328
|
+
this._indicator.style.top = `${y}px`;
|
|
329
|
+
this._indicator.style.left = `${rect.left - containerR.left}px`;
|
|
330
|
+
this._indicator.style.width = `${rect.width}px`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/** True if the drag event carries files (not just text / DOM nodes). */
|
|
338
|
+
function _hasFiles(e) {
|
|
339
|
+
const dt = e.dataTransfer;
|
|
340
|
+
if (!dt) return false;
|
|
341
|
+
// During dragover types is available; during drop files is available
|
|
342
|
+
return dt.types && (dt.types.includes('Files') || dt.types.includes('application/x-moz-file'));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Extract File objects from a drop event, ignoring directories. */
|
|
346
|
+
function _extractFiles(e) {
|
|
347
|
+
const items = e.dataTransfer?.items;
|
|
348
|
+
if (items) {
|
|
349
|
+
return [...items]
|
|
350
|
+
.filter(i => i.kind === 'file')
|
|
351
|
+
.map(i => i.getAsFile())
|
|
352
|
+
.filter(Boolean)
|
|
353
|
+
.filter(f => MIME.image.test(f.type) || MIME.video.test(f.type) || MIME.audio.test(f.type));
|
|
354
|
+
}
|
|
355
|
+
return [...(e.dataTransfer?.files ?? [])]
|
|
356
|
+
.filter(f => MIME.image.test(f.type) || MIME.video.test(f.type) || MIME.audio.test(f.type));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Style injection ───────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
let _stylesInjected = false;
|
|
362
|
+
function _injectStyles() {
|
|
363
|
+
if (_stylesInjected || typeof document === 'undefined') return;
|
|
364
|
+
_stylesInjected = true;
|
|
365
|
+
const s = document.createElement('style');
|
|
366
|
+
s.id = 'griot-drophandler-styles';
|
|
367
|
+
s.textContent = `
|
|
368
|
+
/* Drop insertion line */
|
|
369
|
+
.griot-drop-indicator {
|
|
370
|
+
position: absolute;
|
|
371
|
+
height: 2px;
|
|
372
|
+
background: #6366f1;
|
|
373
|
+
border-radius: 2px;
|
|
374
|
+
pointer-events: none;
|
|
375
|
+
z-index: 100;
|
|
376
|
+
transition: top 0.08s, left 0.08s, width 0.08s;
|
|
377
|
+
}
|
|
378
|
+
.griot-drop-indicator::before,
|
|
379
|
+
.griot-drop-indicator::after {
|
|
380
|
+
content: '';
|
|
381
|
+
position: absolute;
|
|
382
|
+
top: 50%;
|
|
383
|
+
transform: translateY(-50%);
|
|
384
|
+
width: 8px; height: 8px;
|
|
385
|
+
border-radius: 50%;
|
|
386
|
+
background: #6366f1;
|
|
387
|
+
}
|
|
388
|
+
.griot-drop-indicator::before { left: -4px; }
|
|
389
|
+
.griot-drop-indicator::after { right: -4px; }
|
|
390
|
+
|
|
391
|
+
/* Highlight ring when dropping onto an existing image/gallery block */
|
|
392
|
+
.griot-drop-onto {
|
|
393
|
+
outline: 2px solid #6366f1 !important;
|
|
394
|
+
outline-offset: 2px;
|
|
395
|
+
border-radius: 8px;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* Keep the editor container positioned so the absolute indicator works */
|
|
399
|
+
.griot-editor {
|
|
400
|
+
position: relative;
|
|
401
|
+
}
|
|
402
|
+
`;
|
|
403
|
+
document.head.appendChild(s);
|
|
404
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// layouts: 'grid' | 'masonry' | 'carousel' | 'strip'
|
|
12
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
import { lightbox } from '
|
|
14
|
+
import { lightbox } from './Lightbox.js';
|
|
15
15
|
|
|
16
16
|
const VALID_LAYOUTS = new Set(['grid', 'masonry', 'carousel', 'strip']);
|
|
17
17
|
|
package/src/editor/Editor.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
focusAtEnd, focusAtStart,
|
|
29
29
|
} from './Keyboard.js';
|
|
30
30
|
import { FormatToolbar } from './FormatToolbar.js';
|
|
31
|
+
import { DropHandler } from './DropHandler.js';
|
|
31
32
|
import { renderInlineToDOM } from '../inline/InlineRenderer.js';
|
|
32
33
|
|
|
33
34
|
const TYPING_DEBOUNCE_MS = 400;
|
|
@@ -76,6 +77,14 @@ export class Editor {
|
|
|
76
77
|
onLink: () => this._insertLink(),
|
|
77
78
|
onColor: () => this._insertColor(),
|
|
78
79
|
});
|
|
80
|
+
|
|
81
|
+
// Drag-and-drop file handler
|
|
82
|
+
this._drop = new DropHandler(container, {
|
|
83
|
+
getDoc: () => this._doc,
|
|
84
|
+
onCommit: (doc) => this._commit(doc),
|
|
85
|
+
onUpload: options.onUpload ?? undefined,
|
|
86
|
+
uploadUrl: options.uploadUrl ?? undefined,
|
|
87
|
+
});
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
@@ -101,6 +110,7 @@ export class Editor {
|
|
|
101
110
|
destroy() {
|
|
102
111
|
clearTimeout(this._typingTimer);
|
|
103
112
|
this._toolbar.destroy();
|
|
113
|
+
this._drop.destroy();
|
|
104
114
|
this._container.innerHTML = '';
|
|
105
115
|
this._container.classList.remove('griot-editor');
|
|
106
116
|
this._blockEls.clear();
|
|
File without changes
|