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
@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { EditorState } from 'prosemirror-state';
import type { Node as PMNode, Schema } from 'prosemirror-model';
import { initTestEditor } from '@tests/helpers/helpers.js';
import { applyEditableSlotAtInlineBoundary } from './ensure-editable-slot-inline-boundary.js';

function findStructuredContent(doc: PMNode): { node: PMNode; pos: number } | null {
let found: { node: PMNode; pos: number } | null = null;
doc.descendants((node, pos) => {
if (node.type.name === 'structuredContent') {
found = { node, pos };
return false;
}
return true;
});
return found;
}

function zwspCount(text: string): number {
return (text.match(/\u200B/g) ?? []).length;
}

describe('applyEditableSlotAtInlineBoundary', () => {
let schema: Schema;
let destroy: (() => void) | undefined;

beforeEach(() => {
const { editor } = initTestEditor();
schema = editor.schema;
destroy = () => editor.destroy();
});

afterEach(() => {
destroy?.();
destroy = undefined;
});

it('inserts zero-width space after trailing inline SDT (direction after)', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt])]);
const sdt = findStructuredContent(doc);
expect(sdt).not.toBeNull();
const afterSdt = sdt!.pos + sdt!.node.nodeSize;

const state = EditorState.create({ schema, doc });
const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after');

expect(tr.docChanged).toBe(true);
expect(zwspCount(tr.doc.textContent)).toBe(1);
expect(tr.selection.from).toBe(afterSdt + 1);
expect(tr.selection.empty).toBe(true);
});

it('does not insert when text follows inline SDT (direction after)', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
const doc = schema.nodes.doc.create(null, [
schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]),
]);
const sdt = findStructuredContent(doc);
expect(sdt).not.toBeNull();
const afterSdt = sdt!.pos + sdt!.node.nodeSize;
const beforeText = doc.textContent;

const state = EditorState.create({ schema, doc });
const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after');

expect(tr.docChanged).toBe(false);
expect(tr.doc.textContent).toBe(beforeText);
expect(tr.selection.from).toBe(afterSdt);
});

it('inserts zero-width space before leading inline SDT (direction before)', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt, schema.text(' tail')])]);
const sdt = findStructuredContent(doc);
expect(sdt).not.toBeNull();
const beforeSdt = sdt!.pos;

const state = EditorState.create({ schema, doc });
const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before');

expect(tr.docChanged).toBe(true);
expect(zwspCount(tr.doc.textContent)).toBe(1);
expect(tr.doc.textContent).toContain('tail');
expect(tr.selection.from).toBe(beforeSdt + 1);
});

it('does not insert when text precedes inline SDT (direction before)', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt])]);
const sdt = findStructuredContent(doc);
expect(sdt).not.toBeNull();
const beforeSdt = sdt!.pos;
const beforeText = doc.textContent;

const state = EditorState.create({ schema, doc });
const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before');

expect(tr.docChanged).toBe(false);
expect(tr.doc.textContent).toBe(beforeText);
expect(tr.selection.from).toBe(beforeSdt);
});

it('inserts when following sibling is an empty run (direction after)', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
const emptyRun = schema.nodes.run.create();
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt, emptyRun])]);
const sdt = findStructuredContent(doc);
expect(sdt).not.toBeNull();
const afterSdt = sdt!.pos + sdt!.node.nodeSize;

const state = EditorState.create({ schema, doc });
const tr = applyEditableSlotAtInlineBoundary(state.tr, afterSdt, 'after');

expect(tr.docChanged).toBe(true);
expect(zwspCount(tr.doc.textContent)).toBe(1);
expect(tr.selection.from).toBe(afterSdt + 1);
});

it('inserts when preceding sibling is an empty run (direction before)', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
const emptyRun = schema.nodes.run.create();
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [emptyRun, inlineSdt])]);
const sdt = findStructuredContent(doc);
expect(sdt).not.toBeNull();
const beforeSdt = sdt!.pos;

const state = EditorState.create({ schema, doc });
const tr = applyEditableSlotAtInlineBoundary(state.tr, beforeSdt, 'before');

expect(tr.docChanged).toBe(true);
expect(zwspCount(tr.doc.textContent)).toBe(1);
expect(tr.selection.from).toBe(beforeSdt + 1);
});

it('clamps an oversized position to doc end then may insert zero-width space (direction after)', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt])]);
const sizeBefore = doc.content.size;

const state = EditorState.create({ schema, doc });
const tr = applyEditableSlotAtInlineBoundary(state.tr, sizeBefore + 999, 'after');

// Clamps to `doc.content.size`; gap after last inline has no node → ZWSP + caret (size may grow by schema-specific steps).
expect(tr.docChanged).toBe(true);
expect(tr.doc.content.size).toBeGreaterThan(sizeBefore);
expect(zwspCount(tr.doc.textContent)).toBeGreaterThanOrEqual(1);
expect(tr.selection.from).toBeGreaterThan(0);
expect(tr.selection.from).toBeLessThanOrEqual(tr.doc.content.size);
});

it('clamps a negative position to 0 then may insert zero-width space (direction before)', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'i1' }, schema.text('Field'));
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [inlineSdt])]);

const state = EditorState.create({ schema, doc });
const tr = applyEditableSlotAtInlineBoundary(state.tr, -999, 'before');

expect(tr.docChanged).toBe(true);
expect(zwspCount(tr.doc.textContent)).toBe(1);
expect(tr.selection.from).toBe(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Node as PMNode } from 'prosemirror-model';
import { TextSelection, type Transaction } from 'prosemirror-state';

function needsEditableSlot(node: PMNode | null | undefined, side: 'before' | 'after'): boolean {
if (!node) return true;
const name = node.type.name;
if (name === 'hardBreak' || name === 'lineBreak' || name === 'structuredContent') return true;
if (name === 'run') return !(side === 'before' ? node.lastChild?.isText : node.firstChild?.isText);
return false;
}

/**
* Ensures a collapsed caret can live at an inline structuredContent boundary by
* inserting ZWSP when the adjacent slice has no text (keyboard + presentation clicks).
*/
export function applyEditableSlotAtInlineBoundary(
tr: Transaction,
pos: number,
direction: 'before' | 'after',
): Transaction {
const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size));
if (direction === 'before') {
const $pos = tr.doc.resolve(clampedPos);
if (!needsEditableSlot($pos.nodeBefore, 'before')) {
return tr.setSelection(TextSelection.create(tr.doc, clampedPos));
}
tr.insertText('\u200B', clampedPos);
return tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1));
}
if (!needsEditableSlot(tr.doc.nodeAt(clampedPos), 'after')) {
return tr.setSelection(TextSelection.create(tr.doc, clampedPos));
}
tr.insertText('\u200B', clampedPos);
return tr.setSelection(TextSelection.create(tr.doc, clampedPos + 1));
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from '../tables/TableSelectionUtilities.js';
import { debugLog } from '../selection/SelectionDebug.js';
import { DOM_CLASS_NAMES, buildAnnotationSelector, DRAGGABLE_SELECTOR } from '@superdoc/dom-contract';
import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js';
import { isSemanticFootnoteBlockId } from '../semantic-flow-constants.js';
import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js';

Expand All @@ -62,7 +63,6 @@ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray<readonly [number, number]
[0, -COMMENT_THREAD_HIT_TOLERANCE_PX],
[0, COMMENT_THREAD_HIT_TOLERANCE_PX],
];

const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));

type CommentThreadHit = {
Expand Down Expand Up @@ -1294,17 +1294,35 @@ export class EditorInputManager {
// selection so caret placement/editing inside table cells works.
const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null;
let nextSelection: Selection;
let inlineSdtBoundaryPos: number | null = null;
let inlineSdtBoundaryDirection: 'before' | 'after' | null = null;
const insideTableInSdt =
!!sdtBlock && this.#isInsideTableWithinStructuredContentBlock(doc, hit.pos, sdtBlock.pos);
if (sdtBlock && !insideTableInSdt) {
nextSelection = NodeSelection.create(doc, sdtBlock.pos);
} else {
nextSelection = TextSelection.create(doc, hit.pos);
const inlineSdt = clickDepth === 1 ? this.#findStructuredContentInlineAtPos(doc, hit.pos) : null;
if (inlineSdt && hit.pos >= inlineSdt.end) {
const afterInlineSdt = inlineSdt.pos + inlineSdt.node.nodeSize;
inlineSdtBoundaryPos = afterInlineSdt;
inlineSdtBoundaryDirection = 'after';
nextSelection = TextSelection.create(doc, afterInlineSdt);
} else if (inlineSdt && hit.pos <= inlineSdt.start) {
inlineSdtBoundaryPos = inlineSdt.pos;
inlineSdtBoundaryDirection = 'before';
nextSelection = TextSelection.create(doc, inlineSdt.pos);
} else {
nextSelection = TextSelection.create(doc, hit.pos);
}
if (!nextSelection.$from.parent.inlineContent) {
nextSelection = Selection.near(doc.resolve(hit.pos), 1);
}
}
const tr = editor.state.tr.setSelection(nextSelection);
let tr = editor.state.tr.setSelection(nextSelection);
if (inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection) {
tr = applyEditableSlotAtInlineBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection);
nextSelection = tr.selection;
}
// Preserve stored marks (e.g., formatting selected from toolbar before clicking)
if (nextSelection instanceof TextSelection && nextSelection.empty && editor.state.storedMarks) {
tr.setStoredMarks(editor.state.storedMarks);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const { mockTextSelectionCreate, mockNodeSelectionCreate } = vi.hoisted(() => ({
mockTextSelectionCreate: vi.fn(),
mockNodeSelectionCreate: vi.fn(),
}));
const { mockApplyEditableSlotAtInlineBoundary } = vi.hoisted(() => ({
mockApplyEditableSlotAtInlineBoundary: vi.fn(),
}));

vi.mock('../input/PositionHitResolver.js', () => ({
resolvePointerPositionHit: vi.fn(() => ({
Expand All @@ -22,6 +25,10 @@ vi.mock('@superdoc/layout-bridge', () => ({
getFragmentAtPosition: vi.fn(() => null),
}));

vi.mock('@helpers/ensure-editable-slot-inline-boundary.js', () => ({
applyEditableSlotAtInlineBoundary: mockApplyEditableSlotAtInlineBoundary,
}));

vi.mock('prosemirror-state', async (importOriginal) => {
const original = await importOriginal<typeof import('prosemirror-state')>();
return {
Expand Down Expand Up @@ -51,7 +58,7 @@ function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent {
);
}

function createMockDoc(mode: 'tableInSdt' | 'plainSdt') {
function createMockDoc(mode: 'tableInSdt' | 'plainSdt' | 'inlineSdtAfterBoundary') {
return {
content: { size: 200 },
nodeAt: vi.fn(() => ({ nodeSize: 20 })),
Expand All @@ -69,6 +76,19 @@ function createMockDoc(mode: 'tableInSdt' | 'plainSdt') {
end: (depth: number) => (depth === 1 ? 30 : 29),
};
}
if (mode === 'inlineSdtAfterBoundary') {
return {
depth: 2,
node: (depth: number) => {
if (depth === 2) return { type: { name: 'structuredContent' }, nodeSize: 3 };
if (depth === 1) return { type: { name: 'paragraph' } };
return { type: { name: 'doc' } };
},
before: (depth: number) => (depth === 2 ? 10 : 0),
start: (depth: number) => (depth === 2 ? 11 : 1),
end: (depth: number) => (depth === 2 ? 12 : 199),
};
}
return {
depth: 1,
node: (depth: number) => {
Expand Down Expand Up @@ -126,13 +146,21 @@ describe('EditorInputManager structuredContentBlock table exception', () => {
beforeEach(async () => {
mockTextSelectionCreate.mockReset();
mockNodeSelectionCreate.mockReset();
mockApplyEditableSlotAtInlineBoundary.mockReset();
mockTextSelectionCreate.mockReturnValue({
empty: true,
$from: { parent: { inlineContent: true } },
});
mockNodeSelectionCreate.mockReturnValue({
empty: false,
});
mockApplyEditableSlotAtInlineBoundary.mockImplementation((tr) => {
tr.selection = {
empty: true,
$from: { parent: { inlineContent: true } },
};
return tr;
});

viewportHost = document.createElement('div');
visibleHost = document.createElement('div');
Expand Down Expand Up @@ -252,4 +280,27 @@ describe('EditorInputManager structuredContentBlock table exception', () => {
expect(resolvePointerPositionHit as unknown as Mock).toHaveBeenCalled();
expect(mockNodeSelectionCreate).toHaveBeenCalled();
});

it('applies inline structured content boundary handling when the click lands at the trailing edge', () => {
mountWithDoc('inlineSdtAfterBoundary');
const target = document.createElement('span');
viewportHost.appendChild(target);

const PointerEventImpl = getPointerEventImpl();
target.dispatchEvent(
new PointerEventImpl('pointerdown', {
bubbles: true,
cancelable: true,
button: 0,
buttons: 1,
clientX: 28,
clientY: 28,
} as PointerEventInit),
);

expect(resolvePointerPositionHit as unknown as Mock).toHaveBeenCalled();
expect(mockTextSelectionCreate).toHaveBeenCalledWith(mockEditor.state.doc, 13);
expect(mockApplyEditableSlotAtInlineBoundary).toHaveBeenCalledWith(mockEditor.state.tr, 13, 'after');
expect(mockNodeSelectionCreate).not.toHaveBeenCalled();
});
});
Loading
Loading