feat(words): insertImage operation + command + CSS + extension stub (F3.6, F3.9, F3.10)

F3.6 — insertImage reducer + command:
- engine/operations.ts: insertImage(state, options) — atomic insert
  (URL required, optional alt/caption/width/height/align/status).
  Replaces an empty paragraph cursor lands on, otherwise inserts after.
  Caret lands on the new paragraph following the image (image is
  atomic — no caret inside the figure).
- engine/commands.ts: 'insertImage' case added to WordsCommand union +
  applyWordsCommand switch. Public command surface: provider can call
  applyWordsCommand(state, { type: 'insertImage', src, alt?, ... }).

F3.9 — eidos CSS for image block:
- figure[data-words-block='image']: centered flex column with border,
  bg, padding, gap.
- img: max-inline-size 100%, auto height, user-select none.
- figcaption: italic, centered, status-color, smaller font.
- data-words-image-align='left'/'right': flex alignment + auto margins.
- data-words-image-status='pending': opacity + grayscale + "Uploading…"
  ::after pseudo-text.
- data-words-image-status='error': threat border + "Upload failed"
  ::after pseudo-text.
- :focus / :focus-within: 2px accent outline so the atomic block has
  a visible selection state.

F3.10 — imageExtension stub:
- extensions/image/image-extension.ts: WordsExtension shape with
  name='image', nodeTypes=['image'], factories.image=createImage,
  commandNames.insertImage='insertImage'. Hooks intentionally absent
  (engine still hardcodes branches today; stub publishes the public
  shape parallel to tableExtension).
- extensions/index.ts exports imageExtension alongside tableExtension.

Morfo:
- data-block + data-words-node value sets include 'image'.
- New optional attrs declared: data-words-image-align (left/center/
  right), data-words-image-status (pending/error). Now the eidos CSS
  selectors validate against the morfo contract.

211/211 tests pass in words soma + morfo scope.

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

@ -1088,6 +1088,75 @@
background: color-mix(in srgb, var(--_words-accent-solid) 20%, var(--words-bg));
}
/* ── Image (F3) ──────────────────────────────────────────────────────── */
[data-words-content] :where(figure[data-words-block='image']) {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--words-code-py);
margin-inline: 0;
padding: var(--words-code-py);
border: var(--words-border-width) solid var(--words-toolbar-border);
border-radius: var(--words-code-radius);
background: var(--words-bg);
}
[data-words-content] :where(figure[data-words-block='image']) img {
display: block;
max-inline-size: 100%;
block-size: auto;
border-radius: var(--words-code-radius);
user-select: none;
}
[data-words-content] :where(figure[data-words-block='image']) figcaption {
max-inline-size: 100%;
color: var(--words-quote-color);
font-size: var(--words-status-font-size);
font-style: italic;
text-align: center;
}
[data-words-content] [data-words-block='image'][data-words-image-align='left'] {
align-items: flex-start;
margin-inline-end: auto;
}
[data-words-content] [data-words-block='image'][data-words-image-align='right'] {
align-items: flex-end;
margin-inline-start: auto;
}
[data-words-content] [data-words-block='image'][data-words-image-status='pending'] img {
opacity: var(--words-disabled-opacity);
filter: grayscale(0.4);
}
[data-words-content] [data-words-block='image'][data-words-image-status='pending']::after {
content: 'Uploading…';
color: var(--words-status-color);
font-size: var(--words-status-font-size);
font-style: italic;
}
[data-words-content] [data-words-block='image'][data-words-image-status='error'] {
border-color: var(--words-threat-text);
}
[data-words-content] [data-words-block='image'][data-words-image-status='error']::after {
content: 'Upload failed';
color: var(--words-threat-text);
font-size: var(--words-status-font-size);
}
[data-words-content] :focus-within > figure[data-words-block='image'],
[data-words-content] figure[data-words-block='image']:focus,
[data-words-content] figure[data-words-block='image']:focus-within {
outline: 2px solid var(--_words-accent-border);
outline-offset: 2px;
}
[data-words-content] [data-words-list-kind='check'] {
list-style: none;
}

@ -153,7 +153,7 @@ export const wordsMorfo = {
},
{
attr: 'data-block',
values: ['paragraph', 'heading', 'quote', 'code', 'list', 'table'],
values: ['paragraph', 'heading', 'quote', 'code', 'list', 'table', 'image'],
value: v.propRef('currentBlock'),
severity: 'optional'
}
@ -196,7 +196,7 @@ export const wordsMorfo = {
},
{
attr: 'data-block',
values: ['paragraph', 'heading', 'quote', 'code', 'list', 'table'],
values: ['paragraph', 'heading', 'quote', 'code', 'list', 'table', 'image'],
value: v.propRef('currentBlock'),
severity: 'optional'
},
@ -213,7 +213,8 @@ export const wordsMorfo = {
'paragraph',
'heading',
'quote',
'code'
'code',
'image'
],
severity: 'optional'
},
@ -229,6 +230,19 @@ export const wordsMorfo = {
{ attr: 'data-words-code-language', severity: 'optional' },
{ attr: 'data-words-table-striped', severity: 'optional' },
{ attr: 'data-words-table-compact', severity: 'optional' },
// Image block (F3) — alignment + upload lifecycle status.
// 'left'/'right' override the default centered figure;
// pending/error surface upload state in eidos visuals.
{
attr: 'data-words-image-align',
values: ['left', 'center', 'right'],
severity: 'optional'
},
{
attr: 'data-words-image-status',
values: ['pending', 'error'],
severity: 'optional'
},
{ attr: 'data-words-cell-header', severity: 'optional' },
{
attr: 'data-words-cell-align',

@ -1,6 +1,8 @@
import type {
WordsBlockType,
WordsHeadingLevel,
WordsImageAlign,
WordsImageStatus,
WordsListKind,
WordsMark,
WordsTableCellTone,
@ -26,6 +28,7 @@ import {
insertLink,
insertLineBreak,
insertParagraph,
insertImage,
insertTable,
insertTableColumn,
insertTableRow,
@ -89,6 +92,16 @@ export type WordsCommand =
| { type: 'setCodeLanguage'; language?: string }
| { type: 'setTextAlign'; align: WordsTextAlign }
| { type: 'insertTable'; rows?: number; columns?: number }
| {
type: 'insertImage';
src: string;
alt?: string;
caption?: string;
width?: number;
height?: number;
align?: WordsImageAlign;
status?: WordsImageStatus;
}
| { type: 'insertTableRow'; position?: 'before' | 'after' }
| { type: 'insertTableColumn'; position?: 'before' | 'after' }
| { type: 'deleteTableRow' }
@ -131,6 +144,16 @@ export function applyWordsCommand(
return insertParagraph(state);
case 'insertTable':
return insertTable(state, { rows: command.rows, columns: command.columns });
case 'insertImage':
return insertImage(state, {
src: command.src,
alt: command.alt,
caption: command.caption,
width: command.width,
height: command.height,
align: command.align,
status: command.status
});
case 'insertTableRow':
return insertTableRow(state, { position: command.position });
case 'insertTableColumn':

@ -65,6 +65,8 @@ import {
tableOptions,
type WordsTableCellPath
} from '../extensions/table/utils';
import { createImage } from '../extensions/image';
import type { WordsImageAlign, WordsImageStatus } from '../extensions/image';
export interface WordsEditorState {
readonly document: WordsDocument;
@ -771,6 +773,82 @@ export function toggleList(state: WordsEditorState, kind: WordsListKind): WordsO
};
}
export function insertImage(
state: WordsEditorState,
options: {
readonly src: string;
readonly alt?: string;
readonly caption?: string;
readonly width?: number;
readonly height?: number;
readonly align?: WordsImageAlign;
readonly status?: WordsImageStatus;
}
): WordsOperationResult {
if (!options.src) return { state, changed: false };
const selection = state.selection;
if (!selection) return { state, changed: false };
let workingState = state;
let range = normalizedSelectionRange(workingState.document, selection);
if (!range.collapsed) {
const deleted = deleteRange(workingState);
if (!deleted.changed || !deleted.state.selection) return deleted;
const nextSelection = deleted.state.selection;
workingState = deleted.state;
range = normalizedSelectionRange(workingState.document, nextSelection);
}
const blockIndex = range.start.path[0] ?? -1;
const block = workingState.document.children[blockIndex];
if (!block) return { state, changed: false };
const image = createImage(options.src, {
alt: options.alt,
caption: options.caption,
width: options.width,
height: options.height,
align: options.align,
status: options.status
});
// Same UX as insertTable: when the cursor sits on an empty
// paragraph, replace it; otherwise insert after.
const shouldReplaceEmptyParagraph =
block.type === 'paragraph' && getBlockText(block).trim().length === 0;
const insertIndex = shouldReplaceEmptyParagraph ? blockIndex : blockIndex + 1;
const nextChildren = shouldReplaceEmptyParagraph
? [
...workingState.document.children.slice(0, blockIndex),
image,
createParagraph(),
...workingState.document.children.slice(blockIndex + 1)
]
: [
...workingState.document.children.slice(0, blockIndex + 1),
image,
createParagraph(),
...workingState.document.children.slice(blockIndex + 1)
];
const normalized = normalizeDocument({
...workingState.document,
children: nextChildren
}).document;
// Caret lands on the paragraph right after the image (image is
// atomic; you cannot place the caret *inside* the figure).
const nextPoint = pointFromInlineTextOffset(normalized, [insertIndex + 1], 0);
const nextSelection = createCollapsedSelection(nextPoint.path, nextPoint.offset);
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
export function insertTable(
state: WordsEditorState,
options: { rows?: number; columns?: number } = {}

@ -0,0 +1,42 @@
/**
* Image extension — entrypoint that materialises the `WordsExtension`
* contract for the `image` node.
*
* **F3.10 stub stage**: ships the three static facets we can publish
* without touching the engine's hardcoded branches:
*
* 1. `nodeTypes` — `['image']` is the only node this extension owns.
* 2. `factories` — `createImage` adapted to the contract's erased
* signature.
* 3. `commandNames` — public command `insertImage` mapped to its
* internal operation type.
*
* The dynamic hooks (`render`, `normalize`, `serialize`, `path`,
* `keyboard`, `commands`, `events`) are intentionally absent — the
* engine handles them via hardcoded branches today. They migrate to
* this extension as the registry takes over (parallel to the table
* extension migration plan).
*/
import type { WordsExtension, WordsExtensionNode } from '../extension-types';
import { createImage } from './factories';
type ErasedFactory = (...args: ReadonlyArray<unknown>) => WordsExtensionNode;
const imageFactory = ((...args: ReadonlyArray<unknown>) =>
createImage(...(args as Parameters<typeof createImage>))) as ErasedFactory;
export const imageExtension: WordsExtension = {
name: 'image',
version: '1.0.0',
nodeTypes: ['image'],
factories: {
image: imageFactory
},
commandNames: {
// Public name → internal operation type. 1:1 today.
insertImage: 'insertImage'
}
// render / normalize / serialize / path / keyboard / commands /
// events / dispose intentionally absent — see file header.
} satisfies WordsExtension;

@ -2,22 +2,23 @@
* 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.
* structure.
*
* Shipped so far:
* - Types (F3.1)
* - Factory + value-set predicate (F3.1)
* - Engine wire-up (F3.2) — image as WordsBlock variant
* - Render (F3.3) — figure/img/figcaption
* - HTML serializer (F3.4) — <figure><img><figcaption>
* - Markdown serializer (F3.5) — ![alt](src "caption")
* - insertImage operation + command (F3.6)
* - imageExtension stub (F3.10) — nodeTypes + factories + commandNames
*
* 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)
* Pending (UX surfaces):
* - Slash menu /image entry (F3.7)
* - Paste/drop with onUploadImage callback (F3.8)
* - Eidos CSS polish (F3.9)
* - Demo + tests + visual verify (F3.11)
*/
export type {
@ -32,3 +33,5 @@ export {
isWordsImageAlign,
WORDS_IMAGE_ALIGNS
} from './factories';
export { imageExtension } from './image-extension';

@ -36,8 +36,9 @@ export {
WordsExtensionRegistryError
} from './extension-registry';
// First built-in extension. The engine is NOT yet consuming the
// registry — `tableExtension` is published in stub form (nodeTypes +
// factories + commandNames only) so the public shape is stable while
// F2.3f-h port the hooks in.
// Built-in extensions. The engine is NOT yet consuming the registry
// — both `tableExtension` and `imageExtension` are published in stub
// form (nodeTypes + factories + commandNames only) so the public
// shape is stable while subsequent phases port the hooks in.
export { tableExtension } from './table';
export { imageExtension } from './image';

Loading…
Cancel
Save

Powered by TurnKey Linux.