feat(words): image extension foundation — types, render, HTML+Markdown serializers (F3.1-F3.5)

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 <figure data-words-block=image contenteditable=false>
  <img src alt width? height? loading=lazy draggable=false>
  <figcaption>caption</figcaption?
- WordsRenderTag union gains 'figure' | 'img' | 'figcaption'
- data-words-image-align / data-words-image-status surface state
- renderBlockPlainText: image → alt

F3.4 — engine/serialize-html.ts:
- Out: <figure data-words-image-align?><img alt src width? height?><figcaption?></figure>
- In: 'figure' added to BLOCK_TAGS; <figure><img> elements parsed back
  to ImageBlock (with caption from <figcaption>); bare <img> 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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent f25bf1558e
commit 08bef77f4b

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

@ -355,5 +355,8 @@ function getInlineBlockText(block: WordsBlock): string {
)
.join('\n');
}
if (block.type === 'image') {
return block.alt ?? '';
}
return block.children.map(getInlineText).join('');
}

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

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

@ -49,7 +49,10 @@ export type WordsRenderTag =
| 'span'
| 'a'
| 'ul'
| 'mark';
| 'mark'
| 'figure'
| 'img'
| 'figcaption';
export type WordsRenderAttrs = Readonly<Record<string, string | undefined>>;
@ -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('');
}

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

@ -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 `<pre><code${language}>${escapeHtml(block.children.map(getInlineText).join(''))}</code></pre>`;
}
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 = `<img src="${escapeHtmlAttr(block.src)}"${altAttr}${widthAttr}${heightAttr}>`;
const figcaption = block.caption
? `<figcaption>${escapeHtml(block.caption)}</figcaption>`
: '';
return `<figure${alignAttr}>${img}${figcaption}</figure>`;
}
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

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

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

@ -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';

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.