diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index 8f4ee05d4..6e3eb945b 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -36,7 +36,8 @@ export type WordsSlashCommandId = | 'table' | 'unordered-list' | 'ordered-list' - | 'check-list'; + | 'check-list' + | 'image'; export type WordsSelectionRect = { readonly top: number; @@ -62,6 +63,7 @@ export type WordsCommandName = | 'insert-table' | 'insert-table-row' | 'insert-table-column' + | 'insert-image' | 'delete-table-row' | 'delete-table-column' | 'toggle-table-header-row' @@ -208,6 +210,28 @@ export type WordsLinkEditorSnippetProps = { readonly clearError: () => void; }; +/** + * Result a consumer's `onUploadImage` callback must resolve with. The + * provider uses `url` to replace the temporary blob src and optionally + * overrides the placeholder alt. + */ +export interface WordsImageUploadResult { + readonly url: string; + readonly alt?: string; +} + +/** + * Upload pipeline for pasted / dropped image files. Inject from the + * consumer (the app's storage adapter — S3, R2, own backend, etc.). + * Words handles the lifecycle: insert with blob src + status='pending', + * await the promise, then either swap the src in (success) or mark + * status='error' (rejection). When unset, image files are silently + * dropped on paste/drop. + */ +export type WordsOnUploadImage = ( + file: File +) => Promise; + export type WordsProps = WithChild< { id?: string; @@ -222,6 +246,8 @@ export type WordsProps = WithChild< required?: boolean; invalid?: boolean; commitOnBlur?: boolean; + /** Async upload pipeline for pasted/dropped image files. */ + onUploadImage?: WordsOnUploadImage; 'aria-label'?: string; onValueChange?: OnChangeFn; onSelectionChange?: OnChangeFn; diff --git a/src/uix/soma/components/words/words-provider.svelte.test.ts b/src/uix/soma/components/words/words-provider.svelte.test.ts index e65c4977f..fadb60fe2 100644 --- a/src/uix/soma/components/words/words-provider.svelte.test.ts +++ b/src/uix/soma/components/words/words-provider.svelte.test.ts @@ -96,6 +96,9 @@ function wordsOpts(root = document.createElement('div'), value = createEmptyWord onSelectionChange: state<((value: WordsSelection | null) => void) | undefined>(undefined), onValueCommit: state< ((value: WordsDocument, reason: 'programmatic' | 'blur' | 'button') => void) | undefined + >(undefined), + onUploadImage: state< + ((file: File) => Promise<{ url: string; alt?: string }>) | undefined >(undefined) }; } diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index 5285b2d84..f335e5d93 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -17,6 +17,7 @@ import type { WordsBubbleMenuSide, WordsCommandName, WordsCommitReason, + WordsOnUploadImage, WordsSelectionRect, WordsSelectionKind, WordsSlashCommandId, @@ -61,6 +62,7 @@ import { type WordsExportFormat, type WordsEditorState, type WordsHistory, + type WordsImageBlock, type WordsImportFormat, type WordsMark, type WordsPoint, @@ -91,6 +93,7 @@ interface WordsOpts onValueChange: OnChangeFn | undefined; onSelectionChange: OnChangeFn | undefined; onValueCommit: ((document: WordsDocument, reason: WordsCommitReason) => void) | undefined; + onUploadImage: WordsOnUploadImage | undefined; }> {} interface WordsContentOpts extends WithRefOpts {} @@ -925,6 +928,20 @@ export class WordsProvider { return; } + // Image files first — Windows / macOS / Chrome ship them under + // clipboardData.files when the user copies an image from a + // browser / file manager / screenshot tool. If at least one + // image-typed file is present *and* the consumer has wired + // onUploadImage, we own the paste and drop the regular text/html + // processing entirely (mixing both produces awkward layouts). + const imageFiles = collectImageFiles(e.clipboardData?.files); + if (imageFiles.length > 0 && this.opts.onUploadImage.current) { + e.preventDefault(); + this.syncSelectionFromDom({ source: 'input' }); + void this.insertImageFiles(imageFiles, e.currentTarget); + return; + } + const plainText = e.clipboardData?.getData('text/plain') ?? ''; const html = e.clipboardData?.getData('text/html') ?? ''; const action = actionFromPaste({ @@ -942,6 +959,103 @@ export class WordsProvider { this.applyCommand(action.command); }; + readonly ondragover = (e: DragEvent & { currentTarget: HTMLElement }) => { + // Only intercept drags carrying image files; let everything else + // (text drags, internal selection drags) follow the browser + // default so the contenteditable's native drop handling works. + if (this.isDisabled || this.isReadonly) return; + if (!this.opts.onUploadImage.current) return; + if (!dataTransferHasImageFiles(e.dataTransfer)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + }; + + readonly ondrop = (e: DragEvent & { currentTarget: HTMLElement }) => { + if (this.isDisabled || this.isReadonly) return; + if (!this.opts.onUploadImage.current) return; + const imageFiles = collectImageFiles(e.dataTransfer?.files); + if (imageFiles.length === 0) return; + e.preventDefault(); + this.syncSelectionFromDom({ source: 'input' }); + void this.insertImageFiles(imageFiles, e.currentTarget); + }; + + /** + * Insert N image files: each gets a blob URL + status='pending' + * inserted immediately; the consumer's `onUploadImage` callback + * runs in parallel; as each resolves we rewrite the matching + * placeholder block by `src` (which is unique because blob URLs are). + * Failures flip the placeholder to status='error' and keep the blob + * URL so the local preview survives. + */ + private async insertImageFiles(files: readonly File[], target: HTMLElement): Promise { + const upload = this.opts.onUploadImage.current; + if (!upload) return; + const placeholders: { readonly blobUrl: string; readonly alt: string }[] = []; + for (const file of files) { + const node = this.opts.ref.current; + const URLClass = node?.ownerDocument?.defaultView?.URL ?? globalThis.URL; + const blobUrl = URLClass.createObjectURL(file); + const alt = file.name.replace(/\.[^.]+$/, ''); + placeholders.push({ blobUrl, alt }); + this.applyCommand({ + type: 'insertImage', + src: blobUrl, + alt, + status: 'pending' + }); + } + for (const { blobUrl, alt } of placeholders) { + try { + const file = files[placeholders.findIndex((p) => p.blobUrl === blobUrl)]; + if (!file) continue; + const result = await upload(file); + this.replaceImageBlock(blobUrl, { + src: result.url, + alt: result.alt ?? alt + }); + } catch { + this.markImageBlockError(blobUrl); + } + } + // best-effort telemetry trigger; intentionally swallowed if the + // runtime hasn't been started in the current scope (tests, SSR). + try { + void this.runtime.trigger('commit-save-content', { fallbackTarget: target }); + } catch { + /* noop */ + } + } + + /** + * Replace the image block whose current `src` is `previousSrc`. Used + * by the paste/drop upload flow to swap a blob URL for the final + * uploaded URL once the consumer's promise resolves. + */ + private replaceImageBlock( + previousSrc: string, + patch: { readonly src: string; readonly alt?: string } + ): void { + const next = mapImageBlocksBySrc(this.document, previousSrc, (block) => ({ + ...block, + src: patch.src, + ...(patch.alt !== undefined ? { alt: patch.alt } : {}), + status: undefined + })); + if (next === this.document) return; + this.applyCommand({ type: 'replaceDocument', document: next }); + } + + /** Flip an image-block (matched by src) into status='error'. */ + private markImageBlockError(src: string): void { + const next = mapImageBlocksBySrc(this.document, src, (block) => ({ + ...block, + status: 'error' as const + })); + if (next === this.document) return; + this.applyCommand({ type: 'replaceDocument', document: next }); + } + readonly oncompositionstart = (e: CompositionEvent) => { this.composition = beginWordsComposition(e.data ?? ''); }; @@ -1215,7 +1329,23 @@ export class WordsProvider { (id ? this.slashMenuItems.find((entry) => entry.id === id) : undefined) ?? this.slashMenuItems[this.slashMenuActiveIndex]; if (!item) return false; - const command = slashCommandToWordsCommand(item); + let command: WordsCommand | undefined; + if (item.id === 'image') { + // Image insert needs a runtime URL. The MVP uses the browser's + // native prompt; consumers wanting a custom dialog can hide + // the slash menu's 'image' entry (via `slashCommands` prop + // override) and trigger `applyWordsCommand({ type: 'insertImage', + // src, alt? })` from their own toolbar/dialog instead. + const node = this.opts.ref.current; + const win = node?.ownerDocument?.defaultView ?? undefined; + const src = win?.prompt?.('Image URL:')?.trim(); + if (!src) return false; + const altRaw = win?.prompt?.('Alt text (optional):'); + const alt = altRaw?.trim() ? altRaw.trim() : undefined; + command = { type: 'insertImage', src, alt }; + } else { + command = slashCommandToWordsCommand(item); + } if (!command) return false; const selected = setWordsSelection(this.history.present, { @@ -1563,6 +1693,8 @@ export class WordsContentProvider { onselect: this.provider.onselect, onbeforeinput: this.provider.onbeforeinput, onpaste: this.provider.onpaste, + ondragover: this.provider.ondragover, + ondrop: this.provider.ondrop, oncompositionstart: this.provider.oncompositionstart, oncompositionupdate: this.provider.oncompositionupdate, oncompositionend: this.provider.oncompositionend @@ -2443,6 +2575,13 @@ const DEFAULT_SLASH_COMMANDS = [ command: 'check-list', listKind: 'check', keywords: ['todo', 'task', 'check'] + }, + { + id: 'image', + label: 'Image', + description: 'Insert an image from a URL', + command: 'insert-image', + keywords: ['img', 'picture', 'photo', 'figure'] } ] as const satisfies readonly WordsSlashCommandItem[]; @@ -2477,5 +2616,64 @@ function slashCommandToWordsCommand(item: WordsSlashCommandItem): WordsCommand | return { type: 'toggleList', kind: 'ordered' }; case 'check-list': return { type: 'toggleList', kind: 'check' }; + case 'image': + // Image needs a runtime prompt for the URL — handled imperatively + // in commitSlashCommand. Returning undefined here lets that path + // short-circuit if the prompt resolves to an empty URL. + return undefined; } } + +// ── Image paste/drop helpers (F3.8) ───────────────────────────────────── + +function collectImageFiles(files: FileList | null | undefined): File[] { + if (!files || files.length === 0) return []; + const out: File[] = []; + for (let i = 0; i < files.length; i++) { + const f = files.item(i); + if (f && f.type.startsWith('image/')) out.push(f); + } + return out; +} + +function dataTransferHasImageFiles(dt: DataTransfer | null | undefined): boolean { + if (!dt) return false; + // `types` is the most reliable signal during dragover (files aren't + // readable yet on dragover in most browsers — only on drop). The + // 'Files' entry in `types` means at least one file is in the drag. + if (Array.from(dt.types).includes('Files')) { + // items[] is readable on dragover in Chromium and exposes kind+type + // without violating the drop-target restriction. Use it when + // present to filter to image-typed files specifically. + if (dt.items && dt.items.length > 0) { + for (let i = 0; i < dt.items.length; i++) { + const item = dt.items[i]; + if (item.kind === 'file' && item.type.startsWith('image/')) return true; + } + return false; + } + return true; + } + return false; +} + +/** + * Map all image blocks whose `src` matches `previousSrc` through + * `update`, returning a new document. Returns the same reference if + * no block matched (so callers can early-exit on no-op). + */ +function mapImageBlocksBySrc( + document: WordsDocument, + previousSrc: string, + update: (block: WordsImageBlock) => WordsImageBlock +): WordsDocument { + let changed = false; + const children = document.children.map((block) => { + if (block.type !== 'image' || block.src !== previousSrc) return block; + const next = update(block); + if (next !== block) changed = true; + return next; + }); + if (!changed) return document; + return { ...document, children }; +}