From 2a51adc00fdf41ccde06b8cfa4d1181fa891b052 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 20:14:19 +0200 Subject: [PATCH] feat(words): insertImage operation + command + CSS + extension stub (F3.6, F3.9, F3.10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/uix/eidos/components/words/words.css | 69 ++++++++++++++++ src/uix/morfo/components/words.ts | 20 ++++- .../soma/components/words/engine/commands.ts | 23 ++++++ .../components/words/engine/operations.ts | 78 +++++++++++++++++++ .../words/extensions/image/image-extension.ts | 42 ++++++++++ .../words/extensions/image/index.ts | 25 +++--- .../soma/components/words/extensions/index.ts | 9 ++- 7 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 src/uix/soma/components/words/extensions/image/image-extension.ts diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 96d7285b2..ba073b759 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -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; } diff --git a/src/uix/morfo/components/words.ts b/src/uix/morfo/components/words.ts index 6eb68f23c..ea75cc851 100644 --- a/src/uix/morfo/components/words.ts +++ b/src/uix/morfo/components/words.ts @@ -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', diff --git a/src/uix/soma/components/words/engine/commands.ts b/src/uix/soma/components/words/engine/commands.ts index d8d57ca7a..f290b4e15 100644 --- a/src/uix/soma/components/words/engine/commands.ts +++ b/src/uix/soma/components/words/engine/commands.ts @@ -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': diff --git a/src/uix/soma/components/words/engine/operations.ts b/src/uix/soma/components/words/engine/operations.ts index 4ece5718d..aa42ca760 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -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 } = {} diff --git a/src/uix/soma/components/words/extensions/image/image-extension.ts b/src/uix/soma/components/words/extensions/image/image-extension.ts new file mode 100644 index 000000000..7ddb77100 --- /dev/null +++ b/src/uix/soma/components/words/extensions/image/image-extension.ts @@ -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) => WordsExtensionNode; + +const imageFactory = ((...args: ReadonlyArray) => + createImage(...(args as Parameters))) 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; diff --git a/src/uix/soma/components/words/extensions/image/index.ts b/src/uix/soma/components/words/extensions/image/index.ts index 8f5e4c610..e4d5ec0cf 100644 --- a/src/uix/soma/components/words/extensions/image/index.ts +++ b/src/uix/soma/components/words/extensions/image/index.ts @@ -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) —
+ * - 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'; diff --git a/src/uix/soma/components/words/extensions/index.ts b/src/uix/soma/components/words/extensions/index.ts index a792d5c3c..f0d7c6ab6 100644 --- a/src/uix/soma/components/words/extensions/index.ts +++ b/src/uix/soma/components/words/extensions/index.ts @@ -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';