Skip to content
Open
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 @@ -1292,15 +1292,33 @@ export class EditorInputManager {
// SD-1584: clicking inside a block SDT selects the node (NodeSelection).
const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null;
let nextSelection: Selection;
let inlineSdtBoundaryPos: number | null = null;
let inlineSdtBoundaryDirection: 'before' | 'after' | null = null;
if (sdtBlock) {
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 @@ -274,7 +274,54 @@ describe('DomPointerMapping', () => {

expect(result).not.toBeNull();
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThanOrEqual(20);
expect(result).toBeLessThanOrEqual(21);
});

it('returns the position after a terminal inline SDT when clicking to its visual right', () => {
container.innerHTML = `
<div class="superdoc-page" data-page-index="0">
<div class="superdoc-fragment" data-block-id="block1">
<div class="superdoc-line" data-pm-start="2" data-pm-end="25">
<span data-pm-start="2" data-pm-end="8">Date: </span>
<span class="superdoc-structured-content-inline" data-pm-start="11" data-pm-end="25">
<span class="superdoc-structured-content-inline__label">Agreement Date</span>
<span data-pm-start="11" data-pm-end="25">Agreement Date</span>
</span>
</div>
</div>
</div>
`;

const lineRect = container.querySelector('.superdoc-line')!.getBoundingClientRect();
const textSpan = container.querySelector(
'.superdoc-structured-content-inline span[data-pm-start]',
) as HTMLElement;
const spanRect = textSpan.getBoundingClientRect();

expect(clickToPositionDom(container, spanRect.right + 10, lineRect.top + 5)).toBe(26);
});

it('returns the position before a leading inline SDT when clicking to its visual left', () => {
container.innerHTML = `
<div class="superdoc-page" data-page-index="0">
<div class="superdoc-fragment" data-block-id="block1">
<div class="superdoc-line" data-pm-start="11" data-pm-end="25">
<span class="superdoc-structured-content-inline" data-pm-start="11" data-pm-end="25">
<span class="superdoc-structured-content-inline__label">Agreement Date</span>
<span data-pm-start="11" data-pm-end="25">Agreement Date</span>
</span>
</div>
</div>
</div>
`;

const lineRect = container.querySelector('.superdoc-line')!.getBoundingClientRect();
const textSpan = container.querySelector(
'.superdoc-structured-content-inline span[data-pm-start]',
) as HTMLElement;
const spanRect = textSpan.getBoundingClientRect();

expect(clickToPositionDom(container, spanRect.left - 10, lineRect.top + 5)).toBe(10);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ function readPmRange(el: HTMLElement): { start: number; end: number } {
};
}

function getInlineSdtWrapperBoundaryPos(
spanEl: HTMLElement | null | undefined,
side: 'before' | 'after',
): number | null {
if (!(spanEl instanceof HTMLElement)) return null;

const wrapper = spanEl.closest(`.${CLASS.inlineSdtWrapper}`) as HTMLElement | null;
if (!wrapper) return null;

const { start, end } = readPmRange(wrapper);
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;

return side === 'before' ? start - 1 : end + 1;
}

/**
* Collects clickable span/anchor elements inside a line.
*
Expand Down Expand Up @@ -331,8 +346,16 @@ function resolvePositionInLine(
const visualRight = Math.max(...boundsRects.map((r) => r.right));

// Boundary snapping: click outside all spans → return line start/end (RTL-aware)
if (viewX <= visualLeft) return rtl ? lineEnd : lineStart;
if (viewX >= visualRight) return rtl ? lineStart : lineEnd;
if (viewX <= visualLeft) {
const edgeSpan = rtl ? spanEls[spanEls.length - 1] : spanEls[0];
const inlineBoundary = getInlineSdtWrapperBoundaryPos(edgeSpan, rtl ? 'after' : 'before');
return inlineBoundary ?? (rtl ? lineEnd : lineStart);
}
if (viewX >= visualRight) {
const edgeSpan = rtl ? spanEls[0] : spanEls[spanEls.length - 1];
const inlineBoundary = getInlineSdtWrapperBoundaryPos(edgeSpan, rtl ? 'before' : 'after');
return inlineBoundary ?? (rtl ? lineStart : lineEnd);
}

const targetEl = findSpanAtX(spanEls, viewX);
if (!targetEl) return lineStart;
Expand Down
Loading
Loading