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
Expand Up @@ -62,6 +62,7 @@ import {
type HeaderFooterConstraints,
} from '@superdoc/layout-bridge';
import { selectionToRects } from '@superdoc/layout-bridge';
import { resolveCaretLineBox } from '../../../dom-observer/CaretLineAnchoring.js';
import { deduplicateOverlappingRects } from '../../../dom-observer/DomSelectionGeometry.js';
import { resolveSectionProjections } from '../../../document-api-adapters/helpers/sections-resolver.js';
import { computeCaretLayoutRectGeometry as computeCaretLayoutRectGeometryFromHelper } from '../selection/CaretGeometry.js';
Expand Down Expand Up @@ -2236,12 +2237,13 @@ export class HeaderFooterSessionManager {
}

const localX = (pos <= entry.pmStart ? elementRect.left : elementRect.right) - pageRect.left;
const yRect = resolveCaretLineBox(entry.el) ?? elementRect;
return {
pageIndex: context.region.pageIndex,
x: localX / zoom,
y: context.region.pageIndex * bodyPageHeight + (elementRect.top - pageRect.top) / zoom,
y: context.region.pageIndex * bodyPageHeight + (yRect.top - pageRect.top) / zoom,
width: 1,
height: Math.max(1, elementRect.height / zoom),
height: Math.max(1, yRect.height / zoom),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2668,4 +2668,87 @@ describe('HeaderFooterSessionManager', () => {
expect(duringUpdate.storyLayouts.headers[0]!.resolvedLayout!.pages[0]!.items[0]!.blockId).toBe('old-block');
});
});

describe('computeCaretRect — text-less surface entries', () => {
// Page rect is the origin, so page-local coords equal client coords and the
// only interesting term is `pageIndex * bodyPageHeight` = 1 * 800.
const PAGE_RECT = createRect(0, 0, 816, 1056);
const LINE_RECT = createRect(100, 200, 600, 18);
// A painted tab is `vertical-align: bottom` inside a `font-size: 0` line, so its
// own box starts below the line top. That 6px is the caret offset users saw.
const TAB_RECT = createRect(140, 206, 20, 18);

async function setupSurface(entryEl: HTMLElement, { wrapInLine = true } = {}): Promise<void> {
await setupWithZoom(1);

const pageElement = painterHost.querySelector<HTMLElement>('[data-page-index="1"]')!;
vi.spyOn(pageElement, 'getBoundingClientRect').mockReturnValue(PAGE_RECT);

const surface = document.createElement('div');
surface.className = 'superdoc-page-header';
pageElement.appendChild(surface);

vi.spyOn(entryEl, 'getBoundingClientRect').mockReturnValue(TAB_RECT);

if (wrapInLine) {
const line = document.createElement('div');
line.className = 'superdoc-line';
vi.spyOn(line, 'getBoundingClientRect').mockReturnValue(LINE_RECT);
line.appendChild(entryEl);
surface.appendChild(line);
} else {
surface.appendChild(entryEl);
}
}

function createEntryEl(className: string): HTMLElement {
const el = document.createElement('span');
if (className) {
el.className = className;
}
el.dataset.pmStart = '7';
el.dataset.pmEnd = '8';
return el;
}

it('anchors the caret to the line box for a tab entry, not the bottom-aligned tab box', async () => {
await setupSurface(createEntryEl('superdoc-tab'));

expect(manager.computeCaretRect(9)).toEqual({
pageIndex: 1,
// x still comes from the tab box: pos is past pmStart, so the caret sits at its right edge.
x: TAB_RECT.right,
y: 800 + LINE_RECT.top,
width: 1,
height: LINE_RECT.height,
});
});

it('anchors the caret to the line box for an empty SDT placeholder', async () => {
await setupSurface(createEntryEl('superdoc-empty-inline-sdt-placeholder'));

expect(manager.computeCaretRect(9)).toMatchObject({
y: 800 + LINE_RECT.top,
height: LINE_RECT.height,
});
});

it('keeps the element box for text-less entries that are not line-anchored', async () => {
await setupSurface(createEntryEl('superdoc-inline-image'));

expect(manager.computeCaretRect(9)).toMatchObject({
y: 800 + TAB_RECT.top,
height: TAB_RECT.height,
});
});

it('falls back to the element box when a tab entry has no enclosing line', async () => {
await setupSurface(createEntryEl('superdoc-tab'), { wrapInLine: false });

expect(manager.computeCaretRect(9)).toMatchObject({
y: 800 + TAB_RECT.top,
height: TAB_RECT.height,
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { isEmptySdtPlaceholder, isLineAnchoredCaretElement, resolveCaretLineBox } from './CaretLineAnchoring.js';

function createRect(top: number, height: number): DOMRect {
return {
top,
height,
bottom: top + height,
left: 0,
right: 0,
width: 0,
x: 0,
y: top,
toJSON: () => ({}),
} as DOMRect;
}

function paintInLine(className: string, lineRect = createRect(200, 18)): HTMLElement {
const line = document.createElement('div');
line.className = 'superdoc-line';
vi.spyOn(line, 'getBoundingClientRect').mockReturnValue(lineRect);

const el = document.createElement('span');
el.className = className;
line.appendChild(el);
document.body.appendChild(line);
return el;
}

afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});

describe('isEmptySdtPlaceholder', () => {
it.each([
'superdoc-empty-sdt-placeholder',
'superdoc-empty-inline-sdt-placeholder',
'superdoc-empty-block-sdt-placeholder',
])('matches %s', (className) => {
expect(isEmptySdtPlaceholder(paintInLine(className))).toBe(true);
});

it('does not match a tab', () => {
expect(isEmptySdtPlaceholder(paintInLine('superdoc-tab'))).toBe(false);
});
});

describe('isLineAnchoredCaretElement', () => {
it('matches tabs and empty SDT placeholders', () => {
expect(isLineAnchoredCaretElement(paintInLine('superdoc-tab'))).toBe(true);
expect(isLineAnchoredCaretElement(paintInLine('superdoc-empty-inline-sdt-placeholder'))).toBe(true);
});

it('does not match elements whose own box is the caret box', () => {
expect(isLineAnchoredCaretElement(paintInLine('superdoc-inline-image'))).toBe(false);
});
});

describe('resolveCaretLineBox', () => {
it('returns the enclosing line box for a tab', () => {
expect(resolveCaretLineBox(paintInLine('superdoc-tab'))).toMatchObject({ top: 200, height: 18 });
});

it('returns null for elements that are not line-anchored', () => {
expect(resolveCaretLineBox(paintInLine('superdoc-inline-image'))).toBeNull();
});

it('returns null when the tab has no enclosing line', () => {
const orphan = document.createElement('span');
orphan.className = 'superdoc-tab';
document.body.appendChild(orphan);

expect(resolveCaretLineBox(orphan)).toBeNull();
});

it('returns null for a degenerate line box so callers keep the element box', () => {
expect(resolveCaretLineBox(paintInLine('superdoc-tab', createRect(200, 0)))).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { DOM_CLASS_NAMES } from '@superdoc/dom-contract';

const EMPTY_SDT_PLACEHOLDER_CLASSES = [
'superdoc-empty-sdt-placeholder',
'superdoc-empty-inline-sdt-placeholder',
'superdoc-empty-block-sdt-placeholder',
];

/** Placeholder painted for an SDT with no content. */
export function isEmptySdtPlaceholder(el: HTMLElement): boolean {
return EMPTY_SDT_PLACEHOLDER_CLASSES.some((className) => el.classList.contains(className));
}

/**
* AIDEV-NOTE: A tab span is painted `vertical-align: bottom` (SD-3330) inside a `font-size: 0`
* line, so its own box starts below the line top. Every caret path must route through this —
* the header/footer path missed the body-only fix in #3677 and kept rendering the caret low.
*/
export function isLineAnchoredCaretElement(el: HTMLElement): boolean {
return isEmptySdtPlaceholder(el) || el.classList.contains('superdoc-tab');
}

/** The line box a caret should use, or null when the element's own box is correct. */
export function resolveCaretLineBox(el: HTMLElement): DOMRect | null {
if (!isLineAnchoredCaretElement(el)) return null;

const lineRect = el.closest<HTMLElement>(`.${DOM_CLASS_NAMES.LINE}`)?.getBoundingClientRect();
if (!lineRect || !Number.isFinite(lineRect.top) || lineRect.height <= 0) return null;

return lineRect;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Layout } from '@superdoc/contracts';
import { DOM_CLASS_NAMES } from '@superdoc/dom-contract';

import { isEmptySdtPlaceholder, resolveCaretLineBox } from './CaretLineAnchoring.js';
import type { DomPositionIndex, DomPositionIndexEntry } from './DomPositionIndex.js';
import { debugLog, getSelectionDebugConfig } from '../core/presentation-editor/selection/SelectionDebug.js';

Expand Down Expand Up @@ -647,14 +648,8 @@ export function computeDomCaretPageLocal(

// For non-text elements (images, math), position caret at the right edge
// when pos matches pmEnd (cursor after the element)
const isEmptySdtPlaceholder =
targetEl.classList.contains('superdoc-empty-sdt-placeholder') ||
targetEl.classList.contains('superdoc-empty-inline-sdt-placeholder') ||
targetEl.classList.contains('superdoc-empty-block-sdt-placeholder');
const atEnd = isEmptySdtPlaceholder ? pos > entry.pmEnd : pos >= entry.pmEnd;
const useLineTopForY = isEmptySdtPlaceholder || targetEl.classList.contains('superdoc-tab');
const lineEl = useLineTopForY ? (targetEl.closest('.superdoc-line') as HTMLElement | null) : null;
const yRect = lineEl?.getBoundingClientRect() ?? elRect;
const atEnd = isEmptySdtPlaceholder(targetEl) ? pos > entry.pmEnd : pos >= entry.pmEnd;
const yRect = resolveCaretLineBox(targetEl) ?? elRect;
return {
pageIndex: Number(page.dataset.pageIndex ?? '0'),
x: ((atEnd ? elRect.right : elRect.left) - pageRect.left) / zoom,
Expand Down
Loading