From 65cf4bde35f0bbea903cfe8cd6ac36302e8205ba Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 2 Jun 2026 20:26:40 +0200 Subject: [PATCH] fix(words): no trailing paragraph when inserting an image into a column/cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserting an image into a column (slash menu or inspector "Add block") left an empty trailing paragraph beside it. An image is a placeholder configured through the inspector (URL panel), not a block you type after, so the trailing paragraph was pure noise — the reported bug. insertIntoColumn / insertIntoCell / insertBlockInColumn now skip the trailing paragraph for images and return a null (caret-free) selection: the atomic image has no inline text to host a caret, and the provider auto-activates it for the URL panel. A divider keeps its trailing paragraph (it separates typed content); table/callout keep theirs too (they carry inline text, so the caret stays in the block). 5 new engine tests pin the behavior across both insert paths + the cell case; divider regression guarded. Browser-verified: image into a column yields [image] only, no empty text block. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../operations/insert-block-types.test.ts | 103 +++++++++++++++++- .../engine/operations/insert-block-types.ts | 93 +++++++++++----- 2 files changed, 168 insertions(+), 28 deletions(-) diff --git a/src/uix/soma/components/words/engine/operations/insert-block-types.test.ts b/src/uix/soma/components/words/engine/operations/insert-block-types.test.ts index ff619214a..040769250 100644 --- a/src/uix/soma/components/words/engine/operations/insert-block-types.test.ts +++ b/src/uix/soma/components/words/engine/operations/insert-block-types.test.ts @@ -5,7 +5,12 @@ */ import { describe, expect, it } from 'vitest'; -import { insertCallout, insertDivider } from './insert-block-types'; +import { + insertBlockInColumn, + insertCallout, + insertDivider, + insertImage +} from './insert-block-types'; import { createState } from './text'; import { createCollapsedSelection } from '../selection'; import { resolveTextNode } from './helpers'; @@ -74,3 +79,99 @@ describe('insert block into a table cell (P5m)', () => { expect(resolveTextNode(r.state.document, [0, 0, 0, 2, 0])!.text).toBe('b'); }); }); + +function columnsDoc(col0Text = ''): WordsDocument { + return { + version: WORDS_VERSION, + children: [ + { + type: 'columns', + id: 'cols-1', + columns: [ + { + id: 'col-0', + children: [{ type: 'paragraph', children: [{ type: 'text', text: col0Text }] }] + }, + { + id: 'col-1', + children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] + } + ] + } + ] + }; +} + +describe('inserting an image does NOT leave a trailing empty paragraph', () => { + it('caret path (slash menu): image replaces the empty column seed, no trailing', () => { + const d = columnsDoc(); + // Caret in column 0's empty seed paragraph: [cols 0, col 0, para 0, text 0]. + const sel = createCollapsedSelection([0, 0, 0, 0], 0); + const r = insertImage(createState(d, sel), { src: 'cat.jpg' }); + expect(r.changed).toBe(true); + const cols = r.state.document.children[0]; + if (cols.type !== 'columns') throw new Error('expected columns'); + // The column holds ONLY the image — the empty seed was replaced and NO + // trailing paragraph was appended (an image is configured via the + // inspector, not typed after). + expect(cols.columns[0].children.map((b) => b.type)).toEqual(['image']); + // Atomic, no inline text → caret-free. + expect(r.state.selection).toBeNull(); + }); + + it('caret path: a divider STILL keeps its trailing paragraph (regression guard)', () => { + const d = columnsDoc(); + const sel = createCollapsedSelection([0, 0, 0, 0], 0); + const r = insertDivider(createState(d, sel)); + expect(r.changed).toBe(true); + const cols = r.state.document.children[0]; + if (cols.type !== 'columns') throw new Error('expected columns'); + // Divider separates typed content → keeps a line to type below it. + expect(cols.columns[0].children.map((b) => b.type)).toEqual(['divider', 'paragraph']); + }); + + it('caretless path (inspector add-block): image replaces seed, no trailing', () => { + const d = columnsDoc(); + const r = insertBlockInColumn(createState(d), 0, 0, { + type: 'image', + src: 'cat.jpg', + id: 'img-1' + }); + expect(r.changed).toBe(true); + const cols = r.state.document.children[0]; + if (cols.type !== 'columns') throw new Error('expected columns'); + expect(cols.columns[0].children.map((b) => b.type)).toEqual(['image']); + expect(r.state.selection).toBeNull(); + }); + + it('caretless path: a divider STILL keeps its trailing paragraph', () => { + const d = columnsDoc(); + const r = insertBlockInColumn(createState(d), 0, 0, { type: 'divider', id: 'div-1' }); + expect(r.changed).toBe(true); + const cols = r.state.document.children[0]; + if (cols.type !== 'columns') throw new Error('expected columns'); + expect(cols.columns[0].children.map((b) => b.type)).toEqual(['divider', 'paragraph']); + }); + + it('table cell: image replaces the empty cell seed, no trailing', () => { + const d: WordsDocument = { + version: WORDS_VERSION, + children: [ + { + type: 'table', + rows: [ + { cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] }] } + ] + } + ] + }; + // Caret in the cell's empty paragraph: [table 0, row 0, cell 0, para 0, text 0]. + const sel = createCollapsedSelection([0, 0, 0, 0, 0], 0); + const r = insertImage(createState(d, sel), { src: 'cat.jpg' }); + expect(r.changed).toBe(true); + const table = r.state.document.children[0]; + if (table.type !== 'table') throw new Error('expected table'); + expect(table.rows[0].cells[0].children.map((b) => b.type)).toEqual(['image']); + expect(r.state.selection).toBeNull(); + }); +}); diff --git a/src/uix/soma/components/words/engine/operations/insert-block-types.ts b/src/uix/soma/components/words/engine/operations/insert-block-types.ts index 43893cc6f..3c3879d2d 100644 --- a/src/uix/soma/components/words/engine/operations/insert-block-types.ts +++ b/src/uix/soma/components/words/engine/operations/insert-block-types.ts @@ -18,7 +18,7 @@ * normalize / selection model. */ -import { createCollapsedSelection, normalizeRange } from '../selection'; +import { createCollapsedSelection, normalizeRange, type WordsSelection } from '../selection'; import { editingContainerPath, pointFromInlineTextOffset, replaceAt } from './helpers'; import { createCallout, @@ -295,19 +295,26 @@ function insertIntoColumn( innerBlock.children[0].type === 'text' && innerBlock.children[0].text.length === 0; + // An image is a placeholder configured through the inspector (click the + // figure → URL panel), not a block you type after — so it never gets a + // trailing paragraph and never hosts a caret. A divider DOES keep one (it + // separates typed content, so the user needs a line below it). + const isImage = block.type === 'image'; + let nextInner: readonly WordsBlock[]; let newInnerIdx: number; if (isEmptyInner) { - // Always keep a trailing paragraph after the inserted atomic so the - // user can type below it inside the column (mirror of the top-level - // last-block rule). `block` lands at innerIdx; the trailing paragraph - // at innerIdx + 1. + // Keep a trailing paragraph after a divider (or table / callout) so the + // user can type below it (mirror of the top-level last-block rule). An + // image gets none — it would be pure noise. `block` lands at innerIdx; + // the trailing paragraph (when present) at innerIdx + 1. const isLastInner = innerIdx === col.children.length - 1; + const trailing = isImage ? [] : [createParagraph()]; nextInner = isLastInner ? [ ...col.children.slice(0, innerIdx), block, - createParagraph(), + ...trailing, ...col.children.slice(innerIdx + 1) ] : [...col.children.slice(0, innerIdx), block, ...col.children.slice(innerIdx + 1)]; @@ -338,21 +345,27 @@ function insertIntoColumn( children: replaceAt(state.document.children, colsIdx, nextColsBlock) }).document; - // Atomic blocks (image / divider) have no inline text — placing the - // caret on `[colsIdx, colIdx, newInnerIdx]` makes - // `pointFromInlineTextOffset` fall back to a foreign text node - // (usually doc.children[0]), which then dirty-clears any - // `selectedBlockPath` the caller wants to set. When we just added a - // trailing paragraph for the user to keep typing, prefer that paragraph - // as the caret landing — the model selection stays inside the same - // columns block, no false clearSelectedBlock cascade fires, and the - // inserter can mark the atomic as the visually-selected block. - const isAtomicInserted = block.type === 'image' || block.type === 'divider'; + // An image has no inline text to host a caret. Return a null model + // selection so the caret never falls back to a foreign text node (which + // would dirty-clear the image highlight). The inspector add-block path + // auto-activates the new block; slash-menu users click the placeholder. + if (isImage) { + return changed({ document: normalized, selection: null, activeMarks: [] }); + } + + // A divider has no inline text either — placing the caret on + // `[colsIdx, colIdx, newInnerIdx]` makes `pointFromInlineTextOffset` fall + // back to a foreign text node (usually doc.children[0]), which then + // dirty-clears any `selectedBlockPath` the caller wants to set. We added a + // trailing paragraph for the user to keep typing; prefer that paragraph as + // the caret landing. Table / callout carry their own inline text, so their + // caret stays in the block. + const isDividerInserted = block.type === 'divider'; const hasTrailingParagraph = isEmptyInner && newInnerIdx + 1 < (nextCol.children as readonly WordsBlock[]).length && (nextCol.children as readonly WordsBlock[])[newInnerIdx + 1]?.type === 'paragraph'; - const caretInnerIdx = isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx; + const caretInnerIdx = isDividerInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx; const targetPath = opts.caretSubPath ? [colsIdx, colIdx, caretInnerIdx, ...opts.caretSubPath] @@ -399,17 +412,24 @@ function insertIntoCell( innerBlock.children[0].type === 'text' && innerBlock.children[0].text.length === 0; + // An image is a placeholder configured through the inspector, not a block + // you type after — no trailing paragraph, no caret (mirror of the column + // rule). A divider keeps its trailing paragraph. + const isImage = block.type === 'image'; + let nextInner: readonly WordsBlock[]; let newInnerIdx: number; if (isEmptyInner) { - // Keep a trailing paragraph after an inserted atomic so the user can - // keep typing below it inside the cell (mirror of the column rule). + // Keep a trailing paragraph after a divider (or table / callout) so the + // user can keep typing below it inside the cell (mirror of the column + // rule). An image gets none. const isLastInner = innerIdx === cell.children.length - 1; + const trailing = isImage ? [] : [createParagraph()]; nextInner = isLastInner ? [ ...cell.children.slice(0, innerIdx), block, - createParagraph(), + ...trailing, ...cell.children.slice(innerIdx + 1) ] : [...cell.children.slice(0, innerIdx), block, ...cell.children.slice(innerIdx + 1)]; @@ -438,12 +458,20 @@ function insertIntoCell( children: replaceAt(state.document.children, tableIdx, nextTable) }).document; - const isAtomicInserted = block.type === 'image' || block.type === 'divider'; + // Image: atomic, no inline text — return a null model selection so the + // caret never falls back to a foreign text node (mirror of the column rule). + if (isImage) { + return changed({ document: normalized, selection: null, activeMarks: [] }); + } + + // Divider: caret lands in its trailing paragraph. Table / callout carry + // their own inline text, so their caret stays in the block. + const isDividerInserted = block.type === 'divider'; const hasTrailingParagraph = isEmptyInner && newInnerIdx + 1 < (nextCell.children as readonly WordsBlock[]).length && (nextCell.children as readonly WordsBlock[])[newInnerIdx + 1]?.type === 'paragraph'; - const caretInnerIdx = isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx; + const caretInnerIdx = isDividerInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx; const targetPath = opts.caretSubPath ? [tableIdx, rowIdx, cellIdx, caretInnerIdx, ...opts.caretSubPath] @@ -528,8 +556,13 @@ export function insertBlockInColumn( col.children[0].children[0].type === 'text' && col.children[0].children[0].text.length === 0; - const isAtomic = block.type === 'image' || block.type === 'divider'; - const trailing: readonly WordsBlock[] = isAtomic ? [createParagraph()] : []; + // A divider keeps a trailing paragraph (type-after escape hatch). An image + // does NOT — it's a placeholder configured via the inspector (the provider + // auto-activates it by id → URL panel), never typed after. So a trailing + // paragraph would be pure noise. + const isImage = block.type === 'image'; + const isDivider = block.type === 'divider'; + const trailing: readonly WordsBlock[] = isDivider ? [createParagraph()] : []; const baseChildren: readonly WordsBlock[] = isEmptySeed ? [] : col.children; const nextChildren: readonly WordsBlock[] = [...baseChildren, block, ...trailing]; @@ -543,8 +576,14 @@ export function insertBlockInColumn( children: replaceAt(doc.children, colsIdx, nextColsBlock) }).document; - let sel; - if (isAtomic) { + let sel: WordsSelection | null; + if (isImage) { + // Atomic, no inline text: a null selection avoids the foreign-node + // fallback that would dirty-clear the image highlight. The provider + // auto-activates the new block by id, so the inspector follows it to + // the URL panel without a caret. + sel = null; + } else if (isDivider) { // Caret lands inside the trailing paragraph (offset 0). Selection // stays INSIDE the columns block — the column-inserter caller can // then mark the atomic block as visually-selected without the @@ -579,7 +618,7 @@ export function insertBlockInColumn( return changed({ document: normalized, selection: sel, - activeMarks: getActiveMarksForSelection(normalized, sel) + activeMarks: sel ? getActiveMarksForSelection(normalized, sel) : [] }); }