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
36 changes: 34 additions & 2 deletions packages/layout-engine/layout-bridge/src/position-hit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,12 @@ export const hitTestTableFragment = (
return 0;
};

let nearestParagraphHit:
| (Omit<TableHitResult, 'fragment' | 'block' | 'measure' | 'pageIndex' | 'cellRowIndex' | 'cellColIndex'> & {
distance: number;
})
| null = null;

for (let i = 0; i < cellBlocks.length && i < cellBlockMeasures.length; i++) {
const cellBlock = cellBlocks[i];
const cellBlockMeasure = cellBlockMeasures[i];
Expand All @@ -585,8 +591,7 @@ export const hitTestTableFragment = (
const paragraphMeasure = cellBlockMeasure as ParagraphMeasure;

const isWithinBlock = cellLocalY >= blockStartY && cellLocalY < blockEndY;
const isLastParagraph = i === Math.min(cellBlocks.length, cellBlockMeasures.length) - 1;
if (isWithinBlock || isLastParagraph) {
if (isWithinBlock) {
const unclampedLocalY = cellLocalY - blockStartY;
const localYWithinBlock = Math.max(0, Math.min(unclampedLocalY, Math.max(blockHeight, 0)));
return {
Expand All @@ -603,8 +608,35 @@ export const hitTestTableFragment = (
};
}

const distanceToBlock = cellLocalY < blockStartY ? blockStartY - cellLocalY : Math.max(0, cellLocalY - blockEndY);
if (!nearestParagraphHit || distanceToBlock < nearestParagraphHit.distance) {
const unclampedLocalY = cellLocalY - blockStartY;
nearestParagraphHit = {
cellBlock: paragraphBlock,
cellMeasure: paragraphMeasure,
localX: Math.max(0, cellLocalX),
localY: Math.max(0, Math.min(unclampedLocalY, Math.max(blockHeight, 0))),
distance: distanceToBlock,
};
}

blockStartY = blockEndY;
}

if (nearestParagraphHit) {
return {
fragment: tableFragment,
block: tableBlock,
measure: tableMeasure,
pageIndex: pageHit.pageIndex,
cellRowIndex: rowIndex,
cellColIndex: colIndex,
cellBlock: nearestParagraphHit.cellBlock,
cellMeasure: nearestParagraphHit.cellMeasure,
localX: nearestParagraphHit.localX,
localY: nearestParagraphHit.localY,
};
}
}

return null;
Expand Down
112 changes: 112 additions & 0 deletions packages/layout-engine/layout-bridge/test/clickToPosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,118 @@ describe('clickToPosition: table cell empty space', () => {
expect(result!.pos).toBeGreaterThanOrEqual(50);
expect(result!.blockId).toBe('table-block');
});

it('chooses the nearest paragraph when clicking empty space before cell text', () => {
const firstParagraph: FlowBlock = {
kind: 'paragraph',
id: 'cell-para-1',
runs: [{ text: 'First paragraph', fontFamily: 'Arial', fontSize: 14, pmStart: 50, pmEnd: 65 }],
};

const secondParagraph: FlowBlock = {
kind: 'paragraph',
id: 'cell-para-2',
runs: [{ text: 'Second paragraph', fontFamily: 'Arial', fontSize: 14, pmStart: 65, pmEnd: 81 }],
};

const multiParaTableBlock: FlowBlock = {
kind: 'table',
id: 'table-gap-block',
rows: [
{
id: 'row-0',
cells: [
{
id: 'cell-0',
blocks: [firstParagraph, secondParagraph],
attrs: { padding: { top: 2, bottom: 2, left: 4, right: 4 } },
},
],
},
],
};

const multiParaTableMeasure: Measure = {
kind: 'table',
rows: [
{
height: 60,
cells: [
{
width: 200,
height: 60,
gridColumnStart: 0,
blocks: [
{
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 15,
width: 120,
ascent: 10,
descent: 4,
lineHeight: 16,
},
],
totalHeight: 16,
},
{
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 16,
width: 130,
ascent: 10,
descent: 4,
lineHeight: 16,
},
],
totalHeight: 16,
},
],
},
],
},
],
columnWidths: [200],
totalWidth: 200,
totalHeight: 60,
};

const gapLayout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [
{
number: 1,
fragments: [
{
kind: 'table',
blockId: 'table-gap-block',
fromRow: 0,
toRow: 1,
x: 30,
y: 70,
width: 200,
height: 60,
},
],
},
],
};

const result = clickToPosition(gapLayout, [multiParaTableBlock], [multiParaTableMeasure], { x: 50, y: 71 });

expect(result).not.toBeNull();
expect(result!.blockId).toBe('table-gap-block');
expect(result!.pos).toBeGreaterThanOrEqual(50);
expect(result!.pos).toBeLessThanOrEqual(65);
});
});

describe('clickToPosition: table cell on page 2 (multi-page)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,13 @@ export function getFirstTextPosition(doc: ProseMirrorNode | null): number {
}

let validPos = 1;
let found = false;

doc.nodesBetween(0, doc.content.size, (node, pos) => {
if (found) return false;
if (node.isTextblock) {
validPos = pos + 1;
found = true;
return false;
}
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1162,9 +1162,9 @@ export class EditorInputManager {
}
}

// Handle click outside text content
// Handle click outside text content — keep cursor and scroll position unchanged.
if (!rawHit) {
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.

this path never fires during manual testing — the table extends into the margin area, so margin clicks get resolved as table hits instead of reaching here.

this.#focusEditorAtFirstPosition();
this.#focusEditor();
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,47 @@ describe('DomPointerMapping', () => {
});
});

it('resolves through a nested table wrapper when the click lands between lines', () => {
container.innerHTML = `
<div class="superdoc-page" data-page-index="0">
<div class="superdoc-fragment superdoc-table-fragment" data-block-id="table1">
<div class="superdoc-table-cell" style="position: absolute;">
<div class="cell-content">
<div class="paragraph-a" style="margin-bottom: 12px;">
<div class="superdoc-line" data-pm-start="5" data-pm-end="15">
<span data-pm-start="5" data-pm-end="15">Upper line</span>
</div>
</div>
<div class="paragraph-b">
<div class="superdoc-line" data-pm-start="20" data-pm-end="30">
<span data-pm-start="20" data-pm-end="30">Lower line</span>
</div>
</div>
</div>
</div>
</div>
</div>
`;

const page = container.querySelector('.superdoc-page') as HTMLElement;
const tableFragment = container.querySelector('.superdoc-table-fragment') as HTMLElement;
const cell = container.querySelector('.superdoc-table-cell') as HTMLElement;
const content = container.querySelector('.cell-content') as HTMLElement;
const lines = container.querySelectorAll('.superdoc-line') as NodeListOf<HTMLElement>;
const upperRect = lines[0].getBoundingClientRect();
const lowerRect = lines[1].getBoundingClientRect();
const gapY = upperRect.bottom + Math.max(1, (lowerRect.top - upperRect.bottom) / 3);

withMockedElementsFromPoint(
[content, cell, tableFragment, page, container, document.body, document.documentElement],
() => {
const result = clickToPositionDom(container, upperRect.left + 5, gapY);
expect(result).toBeGreaterThanOrEqual(5);
expect(result).toBeLessThanOrEqual(15);
},
);
});

it('returns a position when a line IS in the hit chain', () => {
container.innerHTML = `
<div class="superdoc-page" data-page-index="0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ export function clickToPositionDom(domContainer: HTMLElement, clientX: number, c
return resolveLineAtX(hitChainLine, clientX);
}

if (fragmentEl.classList.contains(CLASS.tableFragment)) {
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.

when a table splits across pages, the container here can hold lines from all pages of that table. so findLineAtY picks the closest line overall, which might be on a different page — and the view scrolls there.

tested with both customer docs, the jump still happens. could you scope this to only lines within the current page fragment?

const scopedContainer = findScopedLineContainer(hitChain, fragmentEl);
if (scopedContainer) {
log('Resolving table click from scoped line container');
return resolveLineInContainer(scopedContainer, clientX, clientY);
}
}

// For table fragments without a direct line hit, defer to geometry
// (hitTestTableFragment resolves the correct cell by column).
if (fragmentEl.classList.contains(CLASS.tableFragment)) {
Expand Down Expand Up @@ -278,6 +286,18 @@ function resolveFragment(fragmentEl: HTMLElement, viewX: number, viewY: number):
return resolveLineAtX(lineEl, viewX);
}

function resolveLineInContainer(containerEl: HTMLElement, viewX: number, viewY: number): number | null {
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.

this function is the same as resolveFragment above — same query, same two calls. only difference is a debug log that's off by default. could reuse resolveFragment and delete this one?

const lineEls = Array.from(containerEl.querySelectorAll(`.${CLASS.line}`)) as HTMLElement[];
if (lineEls.length === 0) {
return null;
}

const lineEl = findLineAtY(lineEls, viewY);
if (!lineEl) return null;

return resolveLineAtX(lineEl, viewX);
}

/**
* Given a known line element, resolves the PM position at the given X
* coordinate.
Expand Down Expand Up @@ -364,12 +384,21 @@ function resolvePositionInLine(
function findLineAtY(lineEls: HTMLElement[], viewY: number): HTMLElement | null {
if (lineEls.length === 0) return null;

let nearest: HTMLElement = lineEls[0];
let minDistance = Infinity;

for (const lineEl of lineEls) {
const r = lineEl.getBoundingClientRect();
if (viewY >= r.top && viewY <= r.bottom) return lineEl;

const distance = viewY < r.top ? r.top - viewY : Math.max(0, viewY - r.bottom);
if (distance < minDistance) {
minDistance = distance;
nearest = lineEl;
}
}

return lineEls[lineEls.length - 1];
return nearest;
}

/**
Expand Down Expand Up @@ -397,6 +426,19 @@ function findSpanAtX(spanEls: HTMLElement[], viewX: number): HTMLElement | null
return nearest;
}

function findScopedLineContainer(hitChain: Element[], fragmentEl: HTMLElement): HTMLElement | null {
for (const el of hitChain) {
if (!(el instanceof HTMLElement)) continue;
if (el === fragmentEl) break;
if (!fragmentEl.contains(el)) continue;
if (el.querySelector(`.${CLASS.line}`)) {
return el;
}
}

return null;
}

// ---------------------------------------------------------------------------
// Character-level position resolution
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading