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:  — escapes ], ( and ) in src, " in caption
- In: standalone line matching ^$ 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
parent
f25bf1558e
commit
08bef77f4b
@ -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;
|
||||
}
|
||||
Loading…
Reference in new issue