/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/web/assets/js/editor.js * Purpose : In-browser text editor overlay — edit code/markdown/text files, * ⌘S saves as a new version * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { h, fmtSize, toast } from './ui.js'; import { UI } from './icons.js'; const EDITABLE_STRATEGIES = new Set(['code', 'markdown', 'structured', 'csv']); const EDIT_SIZE_LIMIT = 4 * 1024 * 1024; /** Can this node be opened in the text editor? */ export function isEditable(node) { return node.type === 'file' && EDITABLE_STRATEGIES.has(node.strategy) && node.size <= EDIT_SIZE_LIMIT; } /** Full-screen editor overlay for one file node. */ export class Editor { /** @param {object} node @param {{onSaved?: (node) => void}} opts */ constructor(node, opts = {}) { this.node = node; this.opts = opts; this.dirty = false; this.saving = false; this.build(); this.load(); } build() { this.textarea = h('textarea.editor-text', { spellcheck: 'false', autocapitalize: 'off', autocomplete: 'off', placeholder: 'Loading…', disabled: true, }); this.status = h('span.editor-status', {}, 'Loading…'); this.saveBtn = h('button.btn.primary', { onclick: () => this.save(), }, 'Save'); this.overlay = h('div.preview-overlay.editor-overlay', { role: 'dialog', 'aria-label': 'Editor' }, h('div.preview-head', {}, h('span.title', {}, this.node.name), h('span.size', {}, fmtSize(this.node.size)), h('span.editor-dirty', { style: { display: 'none' } }, '● unsaved'), h('div.p-actions', {}, this.status, this.saveBtn, h('button.btn.icon', { title: 'Close (Esc)', html: UI.close, onclick: () => this.close() }))), h('div.editor-body', {}, this.textarea)); this.textarea.addEventListener('input', () => this.markDirty(true)); this.textarea.addEventListener('keydown', (e) => { // Tab inserts a real tab instead of moving focus. if (e.key === 'Tab') { e.preventDefault(); const { selectionStart: s, selectionEnd: eEnd, value } = this.textarea; this.textarea.value = `${value.slice(0, s)}\t${value.slice(eEnd)}`; this.textarea.selectionStart = this.textarea.selectionEnd = s + 1; this.markDirty(true); } }); this.onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); this.save(); return; } if (e.key === 'Escape') { e.stopPropagation(); this.close(); } }; document.addEventListener('keydown', this.onKey, true); document.body.append(this.overlay); } async load() { try { const res = await fetch(`/api/v1/preview/${this.node.id}/raw`, { credentials: 'same-origin' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); this.textarea.value = await res.text(); this.textarea.disabled = false; this.textarea.focus(); this.markDirty(false); } catch (err) { this.status.textContent = `Could not load — ${err.message}`; } } markDirty(dirty) { this.dirty = dirty; this.overlay.querySelector('.editor-dirty').style.display = dirty ? '' : 'none'; const lines = (this.textarea.value.match(/\n/g)?.length ?? 0) + 1; this.status.textContent = `${lines} lines · ${fmtSize(new Blob([this.textarea.value]).size)}`; } async save() { if (this.saving || this.textarea.disabled) return; this.saving = true; this.saveBtn.disabled = true; this.status.textContent = 'Saving…'; try { const res = await fetch(`/api/v1/nodes/${this.node.id}/content`, { method: 'PUT', headers: { 'content-type': 'text/plain', 'x-spbdrive-csrf': '1' }, body: this.textarea.value, credentials: 'same-origin', }); const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null; if (!res.ok) throw new Error(data?.error?.message ?? `HTTP ${res.status}`); this.node = data.node; this.markDirty(false); toast('Saved — previous content kept in version history'); this.opts.onSaved?.(data.node); } catch (err) { toast(`Save failed — ${err.message}`, { error: true }); this.status.textContent = 'Save failed'; } finally { this.saving = false; this.saveBtn.disabled = false; } } close() { if (this.dirty && !window.confirm('Discard unsaved changes?')) return; document.removeEventListener('keydown', this.onKey, true); this.overlay.remove(); this.opts.onClose?.(); } }