@0m0g1/griot 0.1.7 → 0.1.9

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0m0g1/griot",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "A self-contained block-based rich text editor and renderer built for historical document authoring.",
5
5
  "type": "module",
6
6
  "main": "./src/Griot.js",
@@ -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
+ }
@@ -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();