fix(words): no trailing paragraph when inserting an image into a column/cell

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent c4acfa4460
commit 65cf4bde35

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.