From 08bef77f4bcf562358b2da505c09ab7319504494 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 20:09:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(words):=20image=20extension=20foundation?= =?UTF-8?q?=20=E2=80=94=20types,=20render,=20HTML+Markdown=20serializers?= =?UTF-8?q?=20(F3.1-F3.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds image as a first-class block node to the words engine. Phase F3 of the words rich-text editor. Ships the bottom half of the stack so images can be authored in source (markdown / HTML / value JSON) and roundtripped, but UX surfaces (toolbar action, slash command, paste/ drop) land in subsequent commits. F3.1 — extensions/image/{types,factories,index}.ts: - WordsImageBlock = { type: 'image', src, alt?, caption?, width?, height?, align?, status? } - createImage(src, options) factory canonicalising defaults (align='center' stripped, etc.) - WordsImageStatus = 'pending' | 'error' for upload lifecycle - WORDS_IMAGE_ALIGNS + isWordsImageAlign predicate F3.2 — engine/document.ts: - WordsBlockType + WordsBlock union extended with 'image' - Image types re-exported (backward-compat with the table-types pattern) - getBlockText: image → alt text (captions excluded; image stays a single atom in plain-text contexts) F3.3 — engine/render.ts: - Image branch renders
caption
- In: 'figure' added to BLOCK_TAGS;
elements parsed back to ImageBlock (with caption from
); bare parses to a block too - htmlElementText helper added for caption text extraction F3.5 — engine/serialize-markdown.ts: - Out: ![alt](src "caption") — escapes ], ( and ) in src, " in caption - In: standalone line matching ^![alt](src "title")$ promotes to a block image. Inline images mid-paragraph become text + no image (lossy by design — keep the model tight; inline image is a separate node type if added later) Engine ripple — exhaustive switches updated: - normalize.getInlineBlockText - path.collectContainerChildren (image marked as terminal) - selection.collectBlockText (image returns empty — opaque atom) - operations: - setBlock skips image (image is not convertible to inline-text blocks) - setTextAlign skips image (no text align on image) - deleteRange refuses if range crosses an image (atomic) - insertParagraph on image inserts a fresh paragraph after it and moves the caret - blockTextAlign() returns undefined for image - words-provider.currentTextAlign returns 'left' on image 149/149 tests pass in the full words soma scope. `npx tsc --noEmit` clean for the touched area. Engine consumers compile unchanged. Next: F3.6 insertImage operation + toolbar/slash command, F3.7 slash menu /image entry, F3.8 paste/drop with onUploadImage callback, F3.9 eidos CSS, F3.10 imageExtension stub, F3.11 demo + verify. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../soma/components/words/engine/document.ts | 36 +++++++++- .../soma/components/words/engine/normalize.ts | 3 + .../components/words/engine/operations.ts | 39 ++++++++++- src/uix/soma/components/words/engine/path.ts | 3 +- .../soma/components/words/engine/render.ts | 53 ++++++++++++++- .../soma/components/words/engine/selection.ts | 4 ++ .../components/words/engine/serialize-html.ts | 67 +++++++++++++++++++ .../words/engine/serialize-markdown.ts | 33 +++++++++ .../words/extensions/image/factories.ts | 53 +++++++++++++++ .../words/extensions/image/index.ts | 34 ++++++++++ .../words/extensions/image/types.ts | 46 +++++++++++++ .../components/words/words-provider.svelte.ts | 5 +- 12 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 src/uix/soma/components/words/extensions/image/factories.ts create mode 100644 src/uix/soma/components/words/extensions/image/index.ts create mode 100644 src/uix/soma/components/words/extensions/image/types.ts diff --git a/src/uix/soma/components/words/engine/document.ts b/src/uix/soma/components/words/engine/document.ts index 924281030..72f7dc327 100644 --- a/src/uix/soma/components/words/engine/document.ts +++ b/src/uix/soma/components/words/engine/document.ts @@ -1,7 +1,14 @@ export const WORDS_DOCUMENT_VERSION = 1; export type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code'; -export type WordsBlockType = 'paragraph' | 'heading' | 'quote' | 'code' | 'list' | 'table'; +export type WordsBlockType = + | 'paragraph' + | 'heading' + | 'quote' + | 'code' + | 'list' + | 'table' + | 'image'; export type WordsListKind = 'ordered' | 'unordered' | 'check'; export type WordsHeadingLevel = 1 | 2 | 3; export type WordsTextAlign = 'left' | 'center' | 'right' | 'justify'; @@ -29,6 +36,23 @@ export type { WordsTableCellOptions }; +// Image types live in the image extension (F3). Re-exported here for +// the same reason as the table types — keep `engine/document` as the +// historical import surface working while new code uses +// `extensions/image` directly. +import type { + WordsImageAlign, + WordsImageBlock, + WordsImageOptions, + WordsImageStatus +} from '../extensions/image/types'; +export type { + WordsImageAlign, + WordsImageBlock, + WordsImageOptions, + WordsImageStatus +}; + export interface WordsDocument { readonly version: typeof WORDS_DOCUMENT_VERSION; readonly children: readonly WordsBlock[]; @@ -40,7 +64,8 @@ export type WordsBlock = | WordsQuoteBlock | WordsCodeBlock | WordsListBlock - | WordsTableBlock; + | WordsTableBlock + | WordsImageBlock; export interface WordsParagraphBlock { readonly type: 'paragraph'; @@ -279,6 +304,13 @@ export function getBlockText(block: WordsBlock): string { ) .join('\n'); } + if (block.type === 'image') { + // Plain-text representation of an image is its alt text. Used + // for word count, copy-as-plaintext and accessibility flows. + // Captions are *not* included to keep the image a single + // semantic atom in plain-text contexts. + return block.alt ?? ''; + } return block.children.map(getInlineText).join(''); } diff --git a/src/uix/soma/components/words/engine/normalize.ts b/src/uix/soma/components/words/engine/normalize.ts index 098648bbf..80ae05688 100644 --- a/src/uix/soma/components/words/engine/normalize.ts +++ b/src/uix/soma/components/words/engine/normalize.ts @@ -355,5 +355,8 @@ function getInlineBlockText(block: WordsBlock): string { ) .join('\n'); } + if (block.type === 'image') { + return block.alt ?? ''; + } return block.children.map(getInlineText).join(''); } diff --git a/src/uix/soma/components/words/engine/operations.ts b/src/uix/soma/components/words/engine/operations.ts index f3935f27d..4ece5718d 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -468,7 +468,9 @@ export function setBlock( return; } - if (block.type === 'table') { + if (block.type === 'table' || block.type === 'image') { + // Tables and images are atomic blocks for the setBlock command — + // not convertible to inline-text-shaped blocks (heading/quote/p). blockIndexMap.set(index, children.length); children.push(block); return; @@ -519,6 +521,7 @@ export function setTextAlign(state: WordsEditorState, align: WordsTextAlign): Wo index > endIndex || block.type === 'list' || block.type === 'table' || + block.type === 'image' || block.type === 'code' ) { children.push(block); @@ -1129,6 +1132,28 @@ export function insertParagraph(state: WordsEditorState): WordsOperationResult { if (!block) return { state, changed: false }; if (block.type === 'code') return insertLineBreak(workingState); + if (block.type === 'image') { + // Image is atomic — Enter inside an image inserts a fresh + // paragraph right after it and moves the caret there. + const normalized = normalizeDocument({ + ...workingState.document, + children: [ + ...workingState.document.children.slice(0, blockIndex + 1), + createParagraph(), + ...workingState.document.children.slice(blockIndex + 1) + ] + }).document; + const point = pointFromInlineTextOffset(normalized, [blockIndex + 1], 0); + const nextSelection = createCollapsedSelection(point.path, point.offset); + return { + state: { + document: normalized, + selection: nextSelection, + activeMarks: getActiveMarksForSelection(normalized, nextSelection) + }, + changed: true + }; + } if (block.type === 'table') return insertLineBreak(workingState); if (block.type === 'list') { @@ -1597,7 +1622,12 @@ function deleteRangeAcrossBlocks( if (!startBlock || !endBlock || endBlockIndex < startBlockIndex) { return { document, point: range.start, changed: false }; } - if (startBlock.type === 'table' || endBlock.type === 'table') { + if ( + startBlock.type === 'table' || + endBlock.type === 'table' || + startBlock.type === 'image' || + endBlock.type === 'image' + ) { return { document, point: range.start, changed: false }; } @@ -2361,7 +2391,10 @@ function isInlineShortcutContent(text: string): boolean { } function blockTextAlign(block: WordsBlock): WordsTextAlign | undefined { - return block.type === 'list' || block.type === 'table' || block.type === 'code' + return block.type === 'list' || + block.type === 'table' || + block.type === 'code' || + block.type === 'image' ? undefined : block.textAlign; } diff --git a/src/uix/soma/components/words/engine/path.ts b/src/uix/soma/components/words/engine/path.ts index f958fdee1..6a37178ee 100644 --- a/src/uix/soma/components/words/engine/path.ts +++ b/src/uix/soma/components/words/engine/path.ts @@ -132,7 +132,8 @@ export function getInlineChildrenAtPath( node.type === 'list' || node.type === 'table' || node.type === 'table-row' || - node.type === 'text' + node.type === 'text' || + node.type === 'image' ) { return undefined; } diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts index e32b30383..ec331c061 100644 --- a/src/uix/soma/components/words/engine/render.ts +++ b/src/uix/soma/components/words/engine/render.ts @@ -49,7 +49,10 @@ export type WordsRenderTag = | 'span' | 'a' | 'ul' - | 'mark'; + | 'mark' + | 'figure' + | 'img' + | 'figcaption'; export type WordsRenderAttrs = Readonly>; @@ -130,6 +133,49 @@ function renderWordsBlock( }; } + if (block.type === 'image') { + const figureChildren: WordsRenderElement[] = [ + { + kind: 'element', + tag: 'img', + attrs: { + src: block.src, + alt: block.alt ?? '', + ...(block.width !== undefined ? { width: String(block.width) } : {}), + ...(block.height !== undefined ? { height: String(block.height) } : {}), + loading: 'lazy', + draggable: 'false' + }, + children: [] + } + ]; + if (block.caption) { + figureChildren.push({ + kind: 'element', + tag: 'figcaption', + attrs: {}, + children: [{ kind: 'text', text: block.caption }] + }); + } + return { + kind: 'element', + tag: 'figure', + attrs: { + [WORDS_NODE_ATTR]: 'block', + [WORDS_PATH_ATTR]: encodeWordsPath(path), + 'data-words-block': 'image', + ...(block.align && block.align !== 'center' + ? { 'data-words-image-align': block.align } + : {}), + ...(block.status ? { 'data-words-image-status': block.status } : {}), + // contenteditable=false so the caret can't enter the + // image atom; selection lands on the figure as a whole. + contenteditable: 'false' + }, + children: figureChildren + }; + } + const tag = block.type === 'heading' ? (`h${block.level}` as const) @@ -318,6 +364,11 @@ function renderBlockPlainText(block: WordsBlock): string { if (block.type === 'table') { return renderTablePlainText(block, { getInlineText }); } + if (block.type === 'image') { + // Plain-text: alt text alone. Captions deliberately excluded so + // the image stays a single semantic atom. + return block.alt ?? ''; + } return block.children.map(getInlineText).join(''); } diff --git a/src/uix/soma/components/words/engine/selection.ts b/src/uix/soma/components/words/engine/selection.ts index b8b6a5cbc..9f871612c 100644 --- a/src/uix/soma/components/words/engine/selection.ts +++ b/src/uix/soma/components/words/engine/selection.ts @@ -96,6 +96,10 @@ function collectBlockText(block: WordsBlock, path: WordsPath): readonly WordsTex ) ); } + if (block.type === 'image') { + // Image is an opaque atom for selection — no inline text inside. + return []; + } return collectInlineText(block.children, path); } diff --git a/src/uix/soma/components/words/engine/serialize-html.ts b/src/uix/soma/components/words/engine/serialize-html.ts index 98ebfa924..cacb7f9d9 100644 --- a/src/uix/soma/components/words/engine/serialize-html.ts +++ b/src/uix/soma/components/words/engine/serialize-html.ts @@ -23,6 +23,7 @@ import { parseTableHtml, serializeTableHtml } from '../extensions/table/serialize-html'; +import { createImage, isWordsImageAlign } from '../extensions/image'; const MARK_TAGS = { bold: 'strong', @@ -50,6 +51,7 @@ const BLOCK_TAGS = new Set([ 'article', 'blockquote', 'div', + 'figure', 'h1', 'h2', 'h3', @@ -127,6 +129,23 @@ function serializeBlockHtml(block: WordsBlock): string { return `
${escapeHtml(block.children.map(getInlineText).join(''))}
`; } + if (block.type === 'image') { + const alignAttr = + block.align && block.align !== 'center' + ? ` data-words-image-align="${escapeHtmlAttr(block.align)}"` + : ''; + const widthAttr = + block.width !== undefined ? ` width="${escapeHtmlAttr(String(block.width))}"` : ''; + const heightAttr = + block.height !== undefined ? ` height="${escapeHtmlAttr(String(block.height))}"` : ''; + const altAttr = ` alt="${escapeHtmlAttr(block.alt ?? '')}"`; + const img = ``; + const figcaption = block.caption + ? `
${escapeHtml(block.caption)}
` + : ''; + return `${img}${figcaption}
`; + } + const align = block.textAlign && block.textAlign !== 'left' ? ` style="text-align: ${block.textAlign}"` : ''; @@ -329,6 +348,48 @@ function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] { return [createQuote(htmlNodesToInlines(node.children), textAlignFromStyle(node))]; } + if (node.name === 'figure') { + const img = firstChildElement(node, 'img'); + if (img) { + const figcaption = firstChildElement(node, 'figcaption'); + const captionText = figcaption ? htmlElementText(figcaption) : undefined; + const alignAttr = node.attrs['data-words-image-align']; + return [ + createImage(typeof img.attrs.src === 'string' ? img.attrs.src : '', { + alt: typeof img.attrs.alt === 'string' ? img.attrs.alt : undefined, + width: + typeof img.attrs.width === 'string' && /^\d+$/.test(img.attrs.width) + ? Number(img.attrs.width) + : undefined, + height: + typeof img.attrs.height === 'string' && /^\d+$/.test(img.attrs.height) + ? Number(img.attrs.height) + : undefined, + caption: captionText && captionText.trim() ? captionText.trim() : undefined, + align: isWordsImageAlign(alignAttr) ? alignAttr : undefined + }) + ]; + } + // figure without img — fall through to the generic block handler + return [createParagraph(htmlNodesToInlines(node.children), textAlignFromStyle(node))]; + } + + if (node.name === 'img') { + return [ + createImage(typeof node.attrs.src === 'string' ? node.attrs.src : '', { + alt: typeof node.attrs.alt === 'string' ? node.attrs.alt : undefined, + width: + typeof node.attrs.width === 'string' && /^\d+$/.test(node.attrs.width) + ? Number(node.attrs.width) + : undefined, + height: + typeof node.attrs.height === 'string' && /^\d+$/.test(node.attrs.height) + ? Number(node.attrs.height) + : undefined + }) + ]; + } + if (node.name === 'ul' || node.name === 'ol') { return [htmlListToBlock(node)]; } @@ -366,6 +427,12 @@ function htmlPreText(node: HtmlNode): string { return node.children.map(htmlPreText).join(''); } +function htmlElementText(node: HtmlElementNode): string { + return node.children + .map((child) => (child.type === 'text' ? child.text : htmlElementText(child))) + .join(''); +} + function htmlPreLanguage( node: HtmlElementNode, code: HtmlElementNode | undefined diff --git a/src/uix/soma/components/words/engine/serialize-markdown.ts b/src/uix/soma/components/words/engine/serialize-markdown.ts index 04cce3ef7..1c93acb86 100644 --- a/src/uix/soma/components/words/engine/serialize-markdown.ts +++ b/src/uix/soma/components/words/engine/serialize-markdown.ts @@ -24,6 +24,7 @@ import { collectTable, serializeTableMarkdown } from '../extensions/table/serialize-markdown'; +import { createImage } from '../extensions/image'; const MARK_DELIMITERS = { bold: ['**', '**'], @@ -89,6 +90,29 @@ export function parseWordsMarkdown(markdown: string): WordsDocument { continue; } + // Block-level image: a line that is *only* `![alt](src "title")`. + // Markdown technically allows images inside paragraphs (inline), but + // the editor's model treats image as a block — when the line stands + // alone we promote it directly to a block image rather than nesting + // it inside a paragraph. Inline images mid-paragraph become regular + // text + no image (lossy, by design — keep the model tight). + const imageLine = line.match( + /^\s*!\[(.*?)\]\(\s*(\S+?)(?:\s+"([^"]*)")?\s*\)\s*$/ + ); + if (imageLine) { + const alt = imageLine[1] ?? ''; + const src = imageLine[2] ?? ''; + const caption = imageLine[3]; + children.push( + createImage(src, { + alt: alt || undefined, + caption: caption || undefined + }) + ); + index += 1; + continue; + } + const list = collectList(lines, index); if (list) { children.push( @@ -152,6 +176,15 @@ function serializeBlockMarkdown(block: WordsBlock): string { return serializeTableMarkdown(block, serializeInlinesMarkdown); } + if (block.type === 'image') { + const alt = (block.alt ?? '').replace(/\]/g, '\\]'); + const src = block.src.replace(/[()]/g, (m) => `\\${m}`); + const title = block.caption + ? ` "${block.caption.replace(/"/g, '\\"')}"` + : ''; + return `![${alt}](${src}${title})`; + } + return serializeInlinesMarkdown(block.children); } diff --git a/src/uix/soma/components/words/extensions/image/factories.ts b/src/uix/soma/components/words/extensions/image/factories.ts new file mode 100644 index 000000000..092ee5bca --- /dev/null +++ b/src/uix/soma/components/words/extensions/image/factories.ts @@ -0,0 +1,53 @@ +/** + * Image factories + value-set predicates. + * + * Owned by the image extension. The engine's `document.ts` re-exports + * these for backward compatibility. New code should import from the + * `extensions/image` barrel. + * + * The factory canonicalises the shape: align='center' is the default + * and is stripped, optional fields are only set when meaningful. This + * keeps document JSON tight and predictable. + */ + +import type { + WordsImageAlign, + WordsImageBlock, + WordsImageOptions +} from './types'; + +export const WORDS_IMAGE_ALIGNS = [ + 'left', + 'center', + 'right' +] as const satisfies readonly WordsImageAlign[]; + +/** + * Construct an image block. + * + * @param src — image URL (http/https/data/blob). + * @param options — alt, caption, width, height, align, status. + * align='center' is the default and is stripped. + */ +export function createImage( + src: string, + options: WordsImageOptions = {} +): WordsImageBlock { + return { + type: 'image', + src, + ...(options.alt ? { alt: options.alt } : {}), + ...(options.caption ? { caption: options.caption } : {}), + ...(options.width !== undefined ? { width: options.width } : {}), + ...(options.height !== undefined ? { height: options.height } : {}), + ...(options.align && options.align !== 'center' ? { align: options.align } : {}), + ...(options.status ? { status: options.status } : {}) + }; +} + +export function isWordsImageAlign(value: unknown): value is WordsImageAlign { + return ( + typeof value === 'string' && + (WORDS_IMAGE_ALIGNS as readonly string[]).includes(value) + ); +} diff --git a/src/uix/soma/components/words/extensions/image/index.ts b/src/uix/soma/components/words/extensions/image/index.ts new file mode 100644 index 000000000..8f5e4c610 --- /dev/null +++ b/src/uix/soma/components/words/extensions/image/index.ts @@ -0,0 +1,34 @@ +/** + * Image extension — public barrel. + * + * Phase F3 of the words rich-text editor. Mirrors the table extension's + * structure. Pieces ship incrementally — this barrel grows as each sub + * (F3.x) lands. + * + * Shipped so far: + * - Types (F3.1) + * - Factory + value-set predicate (F3.1) + * + * Pending: + * - Engine wire-up (F3.2) + * - Render (F3.3) + * - HTML serializer (F3.4) + * - Markdown serializer (F3.5) + * - Operation + toolbar/slash command (F3.6, F3.7) + * - Paste/drop + upload callback (F3.8) + * - Eidos CSS (F3.9) + * - imageExtension stub (F3.10) + */ + +export type { + WordsImageAlign, + WordsImageBlock, + WordsImageOptions, + WordsImageStatus +} from './types'; + +export { + createImage, + isWordsImageAlign, + WORDS_IMAGE_ALIGNS +} from './factories'; diff --git a/src/uix/soma/components/words/extensions/image/types.ts b/src/uix/soma/components/words/extensions/image/types.ts new file mode 100644 index 000000000..1ebff1db8 --- /dev/null +++ b/src/uix/soma/components/words/extensions/image/types.ts @@ -0,0 +1,46 @@ +/** + * Image extension types — the `image` block node. + * + * Owned by the image extension. The engine's `document.ts` adds this + * variant to its `WordsBlock` discriminated union so any block can be + * an image. Consumers should import from the `extensions/image` barrel + * rather than reaching into `engine/document.ts`. + * + * Image is a *block-level* node by design — inline images + * (img-in-paragraph) are out of scope for the MVP and would be a + * separate `image-inline` node type if added later. + */ + +export type WordsImageAlign = 'left' | 'center' | 'right'; + +/** + * Lifecycle marker for images whose `src` is not yet final: + * - absent → image is ready (the `src` resolves) + * - `'pending'` → upload in flight; UI may render placeholder / + * opacity / spinner while the consumer's `onUploadImage` callback + * resolves and the provider rewrites `src` + clears the flag. + * - `'error'` → upload failed; UI surfaces the failure (border tint, + * retry affordance) but keeps the local blob `src` so the user + * does not lose the file. + */ +export type WordsImageStatus = 'pending' | 'error'; + +export interface WordsImageBlock { + readonly type: 'image'; + readonly src: string; + readonly alt?: string; + readonly caption?: string; + readonly width?: number; + readonly height?: number; + readonly align?: WordsImageAlign; + readonly status?: WordsImageStatus; +} + +export interface WordsImageOptions { + readonly alt?: string; + readonly caption?: string; + readonly width?: number; + readonly height?: number; + readonly align?: WordsImageAlign; + readonly status?: WordsImageStatus; +} diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index d69f9ea76..5285b2d84 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -386,7 +386,10 @@ export class WordsProvider { const index = this.selection?.anchor.path[0] ?? 0; const block = this.document.children[index]; if (block?.type === 'table') return this.currentTableCell?.textAlign ?? 'left'; - return block && block.type !== 'list' && block.type !== 'code' + return block && + block.type !== 'list' && + block.type !== 'code' && + block.type !== 'image' ? (block.textAlign ?? 'left') : 'left'; });