Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { $createLinkNode } from '@lexical/link';
import {
$createTableNode,
$createTableRowNode,
Expand All @@ -11,7 +12,7 @@ import {
$createVideoNode,
$isVideoNode,
} from '@macro-inc/lexical-core/nodes/VideoNode';
import type { LexicalEditor } from 'lexical';
import { $createTextNode, $isElementNode, type LexicalEditor } from 'lexical';
import { describe, expect, it } from 'vitest';
import {
$createTextCell,
Expand All @@ -26,6 +27,44 @@ function createTableEditor(): LexicalEditor {
}

describe('table cell allowed content', () => {
it('preserves stray text and inline links in paragraphs, in order', async () => {
const editor = createTableEditor();
await buildTable(editor, textGrid([['hello']]));
let textKey = '';
let linkKey = '';
editor.update(
() => {
const text = $createTextNode('stray text');
const link = $createLinkNode('https://example.com');
link.append($createTextNode('linked text'));
textKey = text.getKey();
linkKey = link.getKey();
$getCell(0, 0).append(text, link);
},
{ discrete: true }
);

editor.read(() => {
const children = $getCell(0, 0).getChildren();
expect(children.map((child) => child.getType())).toEqual([
'paragraph',
'paragraph',
'paragraph',
]);
expect(children.map((child) => child.getTextContent())).toEqual([
'hello',
'stray text',
'linked text',
]);
expect(
$isElementNode(children[1]) && children[1].getFirstChild()?.getKey()
).toBe(textKey);
expect(
$isElementNode(children[2]) && children[2].getFirstChild()?.getKey()
).toBe(linkKey);
});
});

it('keeps an image appended to a table cell', async () => {
const editor = createTableEditor();
await buildTable(editor, textGrid([['hello']]));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
TableCellNode,
} from '@lexical/table';
import { mergeRegister } from '@lexical/utils';
import type { LexicalEditor } from 'lexical';
import { $createParagraphNode, type LexicalEditor } from 'lexical';
import { registerTableListTab } from './tableListTab';
import { registerTableSelectAll } from './tableSelectAll';
import { registerTableTabInsertRow } from './tableTabInsertRow';
Expand Down Expand Up @@ -68,7 +68,7 @@ function _registerTablePlugin(editor: LexicalEditor, props: TablePluginProps) {
})(),

// Restrict table cells to block content that renders sanely inside them
// (notably: no nested tables).
// (notably: no nested tables). Preserve stray text/inlines in a paragraph.
(() => {
const allowedNodesInTableCellNode = [
'paragraph',
Expand All @@ -86,7 +86,13 @@ function _registerTablePlugin(editor: LexicalEditor, props: TablePluginProps) {
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (!allowedNodesInTableCellNode.includes(child.__type)) {
child.remove();
if (child.isInline()) {
const paragraph = $createParagraphNode();
child.replace(paragraph);
paragraph.append(child);
} else {
child.remove();
}
}
}
});
Expand Down
5 changes: 5 additions & 0 deletions docs/AGENT_GUIDE/documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ nodes — use the snapshot itself to verify content. For formatting checks, run
Body placeholder advertises: `/` for block commands, `@` to reference files, `;` for snippets.
Markdown auto-format works while typing (`#` heading, `[]` checklist, `>` quote).

AI text-writing operations require a paragraph/list-item or text-run ID. A
table, row, cell, or list-container ID is rejected with guidance to choose a
content block or use `setCell`. Existing stray inline content directly inside
table cells is preserved in paragraphs when the editor opens the document.

`@` opens the mention menu wherever the caret starts a word, including directly
in front of existing text — the menu opens empty there instead of searching for
the word ahead of the caret. Typed inside a word (`he@llo`) it stays literal text.
Expand Down
113 changes: 113 additions & 0 deletions services/ai-editing-worker/src/ai-editing/doc/cell-id-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { $getId } from '@macro-inc/lexical-core/plugins/nodeIdPlugin';
import { $getRoot, $isElementNode } from 'lexical';
import { describe, expect, it, vi } from 'vitest';
import { read, setup } from '../ai-toolkit/_test-helpers';
import { type DocumentOp, EditError } from '../editor';
import { Doc } from './doc';

const textWrites: DocumentOp[] = [
{ kind: 'setText', node: '', text: 'X' },
{ kind: 'appendText', node: '', text: 'X' },
{ kind: 'prependText', node: '', text: 'X' },
{ kind: 'insertText', node: '', at: 0, text: 'X' },
{
kind: 'insertInline',
node: '',
at: 0,
ref: 'break',
spec: { inline: 'linebreak' },
},
{ kind: 'setBlockType', node: '', block: 'heading', level: 2 },
];

function tableFixture(text = 'original') {
const { session, ids } = setup('intro\n\n- one\n- two');
const propagate = vi.fn();
const doc = new Doc(session, propagate);
doc.apply({
kind: 'insertNode',
ref: 'table',
spec: { block: 'table', rows: [[text]] },
at: { after: ids[0]! },
});
const targets = read(session, () => {
const table = $getRoot()
.getChildren()
.find((n) => n.getType() === 'table');
if (!$isElementNode(table)) throw new Error('missing table');
const row = table.getFirstChild();
if (!$isElementNode(row)) throw new Error('missing row');
const cell = row.getFirstChild();
if (!$isElementNode(cell)) throw new Error('missing cell');
const paragraph = cell.getFirstChild()!;
const list = $getRoot()
.getChildren()
.find((n) => n.getType() === 'list');
if (!$isElementNode(list)) throw new Error('missing list');
return {
table: 'table',
row: $getId(row)!,
cell: $getId(cell)!,
list: $getId(list)!,
paragraph: $getId(paragraph)!,
item: $getId(list.getFirstChild()!)!,
};
});
propagate.mockClear();
return { session, doc, targets, propagate, intro: ids[0]! };
}

describe('text writers reject container ids before changing content', () => {
for (const target of ['table', 'row', 'cell', 'list'] as const) {
for (const op of textWrites) {
it(`${op.kind} rejects a ${target} id, including when empty`, () => {
for (const text of ['original', '']) {
const { session, doc, targets, propagate } = tableFixture(text);
const before = session.editor.getEditorState().toJSON();
const apply = () =>
doc.apply({ ...op, node: targets[target] } as DocumentOp);
expect(apply).toThrow(EditError);
expect(apply).toThrow('target a paragraph or list item id');
expect(session.editor.getEditorState().toJSON()).toEqual(before);
expect(propagate).not.toHaveBeenCalled();
}
});
}
}

for (const target of ['paragraph', 'item'] as const) {
for (const op of textWrites) {
it(`${op.kind} still accepts a ${target} id`, () => {
const { doc, targets, propagate } = tableFixture();
expect(() =>
doc.apply({ ...op, node: targets[target] } as DocumentOp)
).not.toThrow();
expect(propagate).toHaveBeenCalledOnce();
});
}
}

it('keeps setCell, container search/replace, and table moves working', () => {
const { session, doc, targets, intro } = tableFixture();
doc.apply({
kind: 'setCell',
table: targets.table,
row: 0,
col: 0,
text: 'updated',
});
expect(doc.locate(targets.cell, 'updated')).toHaveLength(1);
doc.apply({
kind: 'replaceText',
node: targets.table,
find: 'updated',
to: 'replaced',
scope: { kind: 'all' },
});
doc.apply({ kind: 'moveNode', node: targets.table, at: { before: intro } });
read(session, () => {
expect($getRoot().getFirstChild()?.getType()).toBe('table');
expect($getRoot().getTextContent()).toContain('replaced');
});
});
});
35 changes: 27 additions & 8 deletions services/ai-editing-worker/src/ai-editing/doc/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,24 @@ export class Doc implements DocReader, DocWriter {
return locate.$blockById(this.session, node);
}

/** Text writers need a content block, not a table/list container. Reject
* ambiguous container ids instead of choosing one of their descendants. */
private textBlock(id: NodeRef): ElementNode {
const block = this.block(id);
if (
$isTableNode(block) ||
$isTableRowNode(block) ||
$isTableCellNode(block) ||
$isListNode(block) ||
block.getType() === 'root'
) {
throw new EditError(
`id "${id}" is a ${block.getType()} container — target a paragraph or list item id; use setCell to replace a table cell.`
);
}
return block;
}

/** Resolve a table by id, rejecting non-table ids. `setCell`/cell resolution
* otherwise walk up to the enclosing table, so a <tr>/<td> id "works" but
* reindexes row/col against the table — silently clobbering the wrong cell
Expand Down Expand Up @@ -293,7 +311,7 @@ export class Doc implements DocReader, DocWriter {
const c = target.getTextContent();
target.setTextContent(c.slice(0, at) + text + c.slice(at));
} else {
insertTextAt(this.block(node), at, text);
insertTextAt(this.textBlock(node), at, text);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setText on containers deletes content

High Severity

textBlock rejects table, row, cell, and list ids for setText and insertText, but removeText still goes through this.block(). The live queue expands setText via retype into removeText then insertText. A cell or table id therefore clears every descendant text node and syncs that wipe, then insertText throws and the replacement never lands. Direct doc.apply(setText) tests miss this path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a59591f. Configure here.

}
});
}
Expand Down Expand Up @@ -332,16 +350,16 @@ export class Doc implements DocReader, DocWriter {
else target.setTextContent(text);
return;
}
blocks.$setText(this.block(node), text);
blocks.$setText(this.textBlock(node), text);
});
}

private appendText(node: NodeRef, text: string): void {
this.tx(() => inline.$appendText(this.block(node), text));
this.tx(() => inline.$appendText(this.textBlock(node), text));
}

private prependText(node: NodeRef, text: string): void {
this.tx(() => inline.$prependText(this.block(node), text));
this.tx(() => inline.$prependText(this.textBlock(node), text));
}

private insertTextAfterInline(
Expand Down Expand Up @@ -489,12 +507,13 @@ export class Doc implements DocReader, DocWriter {
: block === 'code'
? { type: 'code', language: opts.language }
: { type: block };
this.tx(() =>
this.tx(() => {
this.textBlock(node);
modify.$modifyNode(this.session, node, {
op: 'blockType',
block: data,
})
);
});
});
}

private setListType(nodes: NodeRef[], list: ListKind): void {
Expand Down Expand Up @@ -586,7 +605,7 @@ export class Doc implements DocReader, DocWriter {
spec: NodeSpec
): void {
this.tx(() => {
const block = this.block(node);
const block = this.textBlock(node);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mergeBlocks still writes bare cell text

Medium Severity

mergeBlocks still resolves ids with $byId and $mergeBlocks appends a separator text node plus the later node's children onto the first node. A cell or table id therefore recreates tablecell → text (or a text child of a table). textBlock does not cover this op, and $assertValidEditorTree no longer runs before commit, so the invalid shape reaches the CRDT.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a59591f. Configure here.

const inline = buildNode(spec);
insertInlineAt(block, at, inline);
this.assignRef(ref, inline);
Expand Down
Loading