feat(words): slash command /image + paste/drop with onUploadImage (F3.7, F3.8)

F3.7 — slash command /image:
- DEFAULT_SLASH_COMMANDS gains an 'image' entry: triggers a window
  prompt for the URL (and optional alt text), then dispatches
  insertImage. The native prompt is MVP — consumers wanting a custom
  dialog can hide the slash entry via slashCommands override and call
  applyWordsCommand({ type: 'insertImage', src }) from their own UI.
- WordsSlashCommandId union + WordsCommandName union extended with
  'image' / 'insert-image' respectively.
- slashCommandToWordsCommand returns undefined for 'image' so the
  prompt path in commitSlashCommand owns it.

F3.8 — paste/drop with onUploadImage callback:
- New WordsProps.onUploadImage prop: (file: File) => Promise<{url, alt?}>.
  Consumer-injected upload pipeline (S3, R2, own backend, etc.).
- onpaste detects image files in clipboardData.files. If present + the
  callback is wired, intercepts the paste (drops text/html processing)
  and inserts each image with a blob: URL + status='pending'.
- New ondragover + ondrop handlers gate on dataTransferHasImageFiles
  and the callback being set; trigger insertImageFiles on drop.
- insertImageFiles inserts pending placeholders, awaits the upload
  promise for each, then either swaps src for the final URL (success)
  or flips status='error' (rejection). Matches blocks by blob URL
  (unique) so concurrent uploads + concurrent edits stay coherent.
- replaceImageBlock / markImageBlockError use mapImageBlocksBySrc to
  rewrite the document via replaceDocument command. No new command
  type needed.
- Helpers added: collectImageFiles, dataTransferHasImageFiles,
  mapImageBlocksBySrc — all pure functions at module scope.

Test fixture: wordsOpts gains the onUploadImage state slot so the 16
existing provider tests typecheck without behavior change.

152/152 tests pass in the full words soma scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent cf9cb0b127
commit ef95b1a82a

@ -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<WordsImageUploadResult>;
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<WordsDocument>;
onSelectionChange?: OnChangeFn<WordsSelection | null>;

@ -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)
};
}

@ -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<WordsDocument> | undefined;
onSelectionChange: OnChangeFn<WordsSelection | null> | 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<void> {
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 };
}

Loading…
Cancel
Save

Powered by TurnKey Linux.