diff --git a/packages/layout-engine/painters/dom/src/_test-utils.ts b/packages/layout-engine/painters/dom/src/_test-utils.ts index 0720080e75..8fb6e6ce4a 100644 --- a/packages/layout-engine/painters/dom/src/_test-utils.ts +++ b/packages/layout-engine/painters/dom/src/_test-utils.ts @@ -155,5 +155,8 @@ export function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measu setScrollContainer(el: HTMLElement | null) { painter.setScrollContainer(el); }, + setShowFormattingMarks(showFormattingMarks: boolean) { + painter.setShowFormattingMarks(showFormattingMarks); + }, }; } diff --git a/packages/layout-engine/painters/dom/src/contract-shape.test.ts b/packages/layout-engine/painters/dom/src/contract-shape.test.ts index b502ee1fb5..a4a32a5f1d 100644 --- a/packages/layout-engine/painters/dom/src/contract-shape.test.ts +++ b/packages/layout-engine/painters/dom/src/contract-shape.test.ts @@ -2,7 +2,7 @@ * Compile-time + runtime contract lockdown for the painter's public surface. * * These assertions fail when someone reintroduces a legacy field on - * `DomPainterInput`, adds a method to `DomPainterHandle`, or makes + * `DomPainterInput`, changes `DomPainterHandle`, or makes * `PageDecorationPayload.items` optional. The boundary tests in * `tests/src/architecture-boundaries.test.ts` cover the import side; this * file covers the type-shape side. @@ -28,7 +28,8 @@ describe('DomPainter public contract shape', () => { | 'getMountedPageIndices' | 'onScroll' | 'setZoom' - | 'setScrollContainer'; + | 'setScrollContainer' + | 'setShowFormattingMarks'; type _Check = AssertTrue>; expectTypeOf().toEqualTypeOf(); }); diff --git a/packages/layout-engine/painters/dom/src/formatting-marks.test.ts b/packages/layout-engine/painters/dom/src/formatting-marks.test.ts new file mode 100644 index 0000000000..c7773ec21e --- /dev/null +++ b/packages/layout-engine/painters/dom/src/formatting-marks.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; +import type { FlowBlock, Layout, Measure } from '@superdoc/contracts'; + +describe('DomPainter formatting marks', () => { + let container: HTMLDivElement; + + beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + function createParagraphBlock(text: string, attrs: FlowBlock['attrs'] = {}): FlowBlock { + return { + kind: 'paragraph', + id: 'paragraph-1', + runs: [ + { + text, + fontFamily: 'Arial', + fontSize: 16, + pmStart: 0, + pmEnd: text.length, + }, + ], + attrs, + }; + } + + function createParagraphMeasure(text: string, width = 80): Measure { + return { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: text.length, + width, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + } + + function createParagraphLayout(): Layout { + return { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'paragraph-1', + fromLine: 0, + toLine: 1, + x: 48, + y: 40, + width: 300, + }, + ], + }, + ], + }; + } + + it('renders space wrappers and a paragraph mark only when enabled', () => { + const text = 'A B C'; + const block = createParagraphBlock(text); + const measure = createParagraphMeasure(text, 72); + const layout = createParagraphLayout(); + + const painter = createDomPainter({ + blocks: [block], + measures: [measure], + showFormattingMarks: true, + }); + + painter.paint(layout, container); + + expect(container.classList.contains('superdoc-show-formatting-marks')).toBe(true); + expect(document.head.querySelector('[data-superdoc-formatting-marks-styles="true"]')).toBeTruthy(); + + const textRun = container.querySelector('span[data-pm-start="0"]'); + expect(textRun?.textContent).toBe(text); + expect(textRun?.querySelectorAll('.superdoc-formatting-space-mark')).toHaveLength(3); + + const paragraphMark = container.querySelector('.superdoc-formatting-paragraph-mark'); + expect(paragraphMark?.textContent).toBe('¶'); + expect(paragraphMark?.style.left).toBe('72px'); + expect(document.head.textContent).toContain('--sd-formatting-paragraph-mark-gap'); + expect(document.head.textContent).toContain( + '[dir="rtl"] .superdoc-formatting-paragraph-mark {\n transform: translateX(calc(-100% - var(--sd-formatting-paragraph-mark-gap, 0.2em)))', + ); + }); + + it('positions paragraph marks after inline-flow paragraph indents', () => { + const text = 'Indented text'; + const block = createParagraphBlock(text, { + indent: { + left: 36, + firstLine: 12, + }, + }); + const measure = createParagraphMeasure(text, 96); + const layout = createParagraphLayout(); + + const painter = createDomPainter({ + blocks: [block], + measures: [measure], + showFormattingMarks: true, + }); + + painter.paint(layout, container); + + const line = container.querySelector('.superdoc-line'); + expect(line?.style.paddingLeft).toBe('36px'); + expect(line?.style.textIndent).toBe('12px'); + + const paragraphMark = container.querySelector('.superdoc-formatting-paragraph-mark'); + expect(paragraphMark?.style.left).toBe('144px'); + }); + + it('positions paragraph marks at the visual text end for centered, right-aligned, and RTL text', () => { + const text = 'Aligned text'; + const measure = createParagraphMeasure(text, 80); + const layout = createParagraphLayout(); + + const centerPainter = createDomPainter({ + blocks: [createParagraphBlock(text, { alignment: 'center' })], + measures: [measure], + showFormattingMarks: true, + }); + + centerPainter.paint(layout, container); + + const centerLine = container.querySelector('.superdoc-line'); + expect(centerLine?.style.textAlign).toBe('center'); + expect(centerLine?.querySelector('.superdoc-formatting-paragraph-mark')?.style.left).toBe('190px'); + + container.innerHTML = ''; + const rightPainter = createDomPainter({ + blocks: [createParagraphBlock(text, { alignment: 'right' })], + measures: [measure], + showFormattingMarks: true, + }); + + rightPainter.paint(layout, container); + + const rightLine = container.querySelector('.superdoc-line'); + expect(rightLine?.style.textAlign).toBe('right'); + expect(rightLine?.querySelector('.superdoc-formatting-paragraph-mark')?.style.left).toBe('300px'); + + container.innerHTML = ''; + const rtlPainter = createDomPainter({ + blocks: [createParagraphBlock(text, { direction: 'rtl' })], + measures: [measure], + showFormattingMarks: true, + }); + + rtlPainter.paint(layout, container); + + const rtlLine = container.querySelector('.superdoc-line'); + expect(rtlLine?.dir).toBe('rtl'); + expect(rtlLine?.style.textAlign).toBe('right'); + expect(rtlLine?.querySelector('.superdoc-formatting-paragraph-mark')?.style.left).toBe('220px'); + }); + + it('renders paragraph marks only on the final visual line of wrapped paragraphs', () => { + const text = 'Wrapped paragraph text'; + const block = createParagraphBlock(text); + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 64, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 8, + toRun: 0, + toChar: text.length, + width: 112, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + const layout = createParagraphLayout(); + layout.pages[0].fragments[0].toLine = 2; + + const painter = createDomPainter({ + blocks: [block], + measures: [measure], + showFormattingMarks: true, + }); + + painter.paint(layout, container); + + const lines = container.querySelectorAll('.superdoc-line'); + expect(lines[0].querySelector('.superdoc-formatting-paragraph-mark')).toBeNull(); + + const paragraphMark = lines[1].querySelector('.superdoc-formatting-paragraph-mark'); + expect(container.querySelectorAll('.superdoc-formatting-paragraph-mark')).toHaveLength(1); + expect(paragraphMark?.textContent).toBe('¶'); + expect(paragraphMark?.style.left).toBe('112px'); + }); + + it('renders paragraph marks only on the final visual line when a paragraph ends with an inline image', () => { + const imageSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const block: FlowBlock = { + kind: 'paragraph', + id: 'paragraph-1', + runs: [ + { + text: 'Text', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 0, + pmEnd: 4, + }, + { + kind: 'image', + src: imageSrc, + width: 20, + height: 20, + pmStart: 4, + pmEnd: 5, + }, + ], + attrs: {}, + }; + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 4, + width: 32, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 1, + fromChar: 0, + toRun: 1, + toChar: 1, + width: 20, + ascent: 16, + descent: 4, + lineHeight: 24, + }, + ], + totalHeight: 44, + }; + const layout = createParagraphLayout(); + layout.pages[0].fragments[0].toLine = 2; + + const painter = createDomPainter({ + blocks: [block], + measures: [measure], + showFormattingMarks: true, + }); + + painter.paint(layout, container); + + const lines = container.querySelectorAll('.superdoc-line'); + expect(lines[0].querySelector('.superdoc-formatting-paragraph-mark')).toBeNull(); + expect(lines[1].querySelector('.superdoc-formatting-paragraph-mark')?.style.left).toBe('20px'); + expect(container.querySelectorAll('.superdoc-formatting-paragraph-mark')).toHaveLength(1); + }); + + it('does not add formatting mark DOM when disabled', () => { + const text = 'A B'; + const block = createParagraphBlock(text); + const measure = createParagraphMeasure(text); + const layout = createParagraphLayout(); + + const painter = createDomPainter({ blocks: [block], measures: [measure] }); + + painter.paint(layout, container); + + expect(container.classList.contains('superdoc-show-formatting-marks')).toBe(false); + expect(container.querySelector('.superdoc-formatting-space-mark')).toBeNull(); + expect(container.querySelector('.superdoc-formatting-paragraph-mark')).toBeNull(); + }); + + it('can toggle formatting marks on an existing painter', () => { + const text = 'A B'; + const block = createParagraphBlock(text); + const measure = createParagraphMeasure(text); + const layout = createParagraphLayout(); + + const painter = createDomPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, container); + expect(container.querySelector('.superdoc-formatting-paragraph-mark')).toBeNull(); + + painter.setShowFormattingMarks(true); + painter.paint(layout, container); + expect(container.classList.contains('superdoc-show-formatting-marks')).toBe(true); + expect(container.querySelector('.superdoc-formatting-paragraph-mark')).toBeTruthy(); + + painter.setShowFormattingMarks(false); + painter.paint(layout, container); + expect(container.classList.contains('superdoc-show-formatting-marks')).toBe(false); + expect(container.querySelector('.superdoc-formatting-paragraph-mark')).toBeNull(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index ef26279d6d..ac1074cebb 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -5711,6 +5711,7 @@ describe('DomPainter', () => { width: 300, pmStart: 1, pmEnd: 15, + markerTextWidth: 12, }, ], }, @@ -5764,6 +5765,7 @@ describe('DomPainter', () => { const painter = createTestPainter({ blocks: [paragraphBlock], measures: [paragraphMeasure], + showFormattingMarks: true, }); painter.setResolvedLayout(resolvedLayout); @@ -5772,10 +5774,15 @@ describe('DomPainter', () => { const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; const markerEl = mount.querySelector('.superdoc-paragraph-marker') as HTMLElement; const tabEl = mount.querySelector('.superdoc-tab') as HTMLElement; + const paragraphMark = mount.querySelector('.superdoc-formatting-paragraph-mark') as HTMLElement; expect(markerEl.textContent).toBe('1.'); expect(lineEl.style.paddingLeft).toBe('36px'); + expect(tabEl.classList.contains('superdoc-marker-suffix-tab')).toBe(true); expect(tabEl.style.width).toBe('24px'); + expect(tabEl.style.fontSize).toBe('12px'); + expect(paragraphMark.textContent).toBe('¶'); + expect(paragraphMark.style.left).toBe('232px'); }); it('renders a resolved drop cap without a legacy descriptor on the block', () => { diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index b15bce4881..5d91414acb 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -99,6 +99,8 @@ export type DomPainterOptions = { ruler?: RulerOptions; /** Called with the paint snapshot after each paint cycle completes. */ onPaintSnapshot?: (snapshot: PaintSnapshot) => void; + /** Render nonprinting formatting marks such as spaces, tabs, and paragraph marks. */ + showFormattingMarks?: boolean; }; export type DomPainterHandle = { @@ -109,6 +111,7 @@ export type DomPainterHandle = { onScroll(): void; setZoom(zoom: number): void; setScrollContainer(el: HTMLElement | null): void; + setShowFormattingMarks(showFormattingMarks: boolean): void; }; /** @@ -143,5 +146,8 @@ export const createDomPainter = (options: DomPainterOptions): DomPainterHandle = setScrollContainer(el: HTMLElement | null) { painter.setScrollContainer(el); }, + setShowFormattingMarks(showFormattingMarks: boolean) { + painter.setShowFormattingMarks(showFormattingMarks); + }, }; }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index d87b946b89..43c2cab6c4 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -53,6 +53,7 @@ import type { ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem, + ResolvedListMarkerItem, } from '@superdoc/contracts'; import { adjustAvailableWidthForTextIndent, @@ -90,6 +91,7 @@ import { containerStyles, containerStylesHorizontal, ensureFieldAnnotationStyles, + ensureFormattingMarksStyles, ensureImageSelectionStyles, ensureLinkStyles, ensureMathMencloseStyles, @@ -312,6 +314,8 @@ type PainterOptions = { ruler?: RulerOptions; /** Called with the paint snapshot after each paint cycle completes. */ onPaintSnapshot?: (snapshot: PaintSnapshot) => void; + /** Render nonprinting formatting marks such as spaces, tabs, and paragraph marks. */ + showFormattingMarks?: boolean; }; type FragmentDomState = { @@ -1337,6 +1341,7 @@ export class DomPainter { private mountedPageIndices: number[] = []; /** Resolved layout for the next-gen paint pipeline. */ private resolvedLayout: ResolvedLayout | null = null; + private showFormattingMarks = false; constructor(options: PainterOptions = {}) { this.options = options; @@ -1344,6 +1349,7 @@ export class DomPainter { this.isSemanticFlow = (options.flowMode ?? 'paginated') === 'semantic'; this.headerProvider = options.headerProvider; this.footerProvider = options.footerProvider; + this.showFormattingMarks = options.showFormattingMarks === true; // Initialize page gap (defaults: 24px vertical, 20px horizontal) const defaultGap = this.layoutMode === 'horizontal' ? 20 : 24; @@ -1373,11 +1379,36 @@ export class DomPainter { this.onPaintSnapshotCallback = options.onPaintSnapshot ?? null; } + public setShowFormattingMarks(showFormattingMarks: boolean): void { + const next = showFormattingMarks === true; + if (this.showFormattingMarks === next) return; + this.showFormattingMarks = next; + this.applyFormattingMarksClass(); + this.invalidateRenderedContent(); + } + public setProviders(header?: PageDecorationProvider, footer?: PageDecorationProvider): void { this.headerProvider = header; this.footerProvider = footer; } + private applyFormattingMarksClass(mount: HTMLElement | null = this.mount): void { + mount?.classList.toggle('superdoc-show-formatting-marks', this.showFormattingMarks); + } + + private invalidateRenderedContent(): void { + this.pageStates = []; + this.currentLayout = null; + this.pageIndexToState.clear(); + this.virtualMountedKey = ''; + this.clearGapSpacers(); + this.topSpacerEl = null; + this.bottomSpacerEl = null; + this.virtualPagesEl = null; + this.processedLayoutVersion = -1; + this.layoutVersion += 1; + } + /** * Pins specific page indices so they remain mounted when virtualization is enabled. * @@ -1649,6 +1680,7 @@ export class DomPainter { ensurePrintStyles(doc); ensureLinkStyles(doc); ensureTrackChangeStyles(doc); + ensureFormattingMarksStyles(doc); ensureFieldAnnotationStyles(doc); ensureSdtContainerStyles(doc); ensureImageSelectionStyles(doc); @@ -1657,9 +1689,11 @@ export class DomPainter { ensureRulerStyles(doc); } mount.classList.add(CLASS_NAMES.container); + this.applyFormattingMarksClass(mount); if (this.mount && this.mount !== mount) { this.resetState(); + this.applyFormattingMarksClass(mount); } this.layoutVersion += 1; @@ -3130,6 +3164,12 @@ export class DomPainter { const expandedRunsForBlock = expandRunsForInlineNewlines(block.runs); content.lines.forEach((resolvedLine) => { + const paragraphMarkLeftOffset = this.resolveResolvedListParagraphMarkOffset( + resolvedLine.isListFirstLine ? resolvedMarker : undefined, + fragment.markerTextWidth, + resolvedLine.indentOffset, + ); + const lineEl = this.renderLine( block, resolvedLine.line, @@ -3140,6 +3180,7 @@ export class DomPainter { expandedRunsForBlock, resolvedLine.resolvedListTextStartPx, resolvedLine.indentOffset, + paragraphMarkLeftOffset, ); // Apply pre-computed indent values @@ -3208,15 +3249,17 @@ export class DomPainter { if (resolvedMarker.suffix === 'tab') { const tabEl = this.doc!.createElement('span'); - tabEl.className = 'superdoc-tab'; + tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); tabEl.innerHTML = ' '; tabEl.style.display = 'inline-block'; + tabEl.style.fontSize = `${resolvedMarker.run.fontSize}px`; tabEl.style.wordSpacing = '0px'; tabEl.style.width = `${resolvedMarker.suffixWidthPx}px`; lineEl.prepend(tabEl); } else if (resolvedMarker.suffix === 'space') { const spaceEl = this.doc!.createElement('span'); spaceEl.classList.add('superdoc-marker-suffix-space'); + spaceEl.style.fontSize = `${resolvedMarker.run.fontSize}px`; spaceEl.style.wordSpacing = '0px'; spaceEl.textContent = '\u00A0'; lineEl.prepend(spaceEl); @@ -3427,15 +3470,17 @@ export class DomPainter { const suffix = marker.suffix ?? 'tab'; if (suffix === 'tab') { const tabEl = this.doc!.createElement('span'); - tabEl.className = 'superdoc-tab'; + tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); tabEl.innerHTML = ' '; tabEl.style.display = 'inline-block'; + tabEl.style.fontSize = `${marker.run.fontSize}px`; tabEl.style.wordSpacing = '0px'; tabEl.style.width = `${listTabWidth}px`; lineEl.prepend(tabEl); } else if (suffix === 'space') { const spaceEl = this.doc!.createElement('span'); spaceEl.classList.add('superdoc-marker-suffix-space'); + spaceEl.style.fontSize = `${marker.run.fontSize}px`; spaceEl.style.wordSpacing = '0px'; spaceEl.textContent = '\u00A0'; lineEl.prepend(spaceEl); @@ -5326,6 +5371,138 @@ export class DomPainter { return wrapper; } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { + if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { + element.textContent = text; + return; + } + + element.textContent = ''; + let chunkStart = 0; + for (let index = 0; index < text.length; index += 1) { + if (text[index] !== ' ') continue; + + if (index > chunkStart) { + element.appendChild(this.doc.createTextNode(text.slice(chunkStart, index))); + } + + const space = this.doc.createElement('span'); + space.classList.add('superdoc-formatting-space-mark'); + space.textContent = ' '; + element.appendChild(space); + chunkStart = index + 1; + } + + if (chunkStart < text.length) { + element.appendChild(this.doc.createTextNode(text.slice(chunkStart))); + } + } + + private findLastTextRun(runs: Run[]): { run: TextRun; index: number } | null { + for (let index = runs.length - 1; index >= 0; index -= 1) { + const run = runs[index]; + if (run && (run.kind === 'text' || run.kind === undefined) && 'text' in run) { + return { run: run as TextRun, index }; + } + } + return null; + } + + private appendFormattingParagraphMark( + lineEl: HTMLElement, + line: Line, + runs: Run[], + leftOffsetPx: number, + availableWidth: number, + hasExplicitPositioning: boolean, + ): void { + if (!this.showFormattingMarks || !this.doc) return; + const lastRun = runs.length > 0 ? runs[runs.length - 1] : null; + if (lastRun) { + const lastRunIndex = runs.length - 1; + if (line.toRun < lastRunIndex) return; + if ( + line.toRun === lastRunIndex && + (lastRun.kind === 'text' || lastRun.kind === undefined) && + 'text' in lastRun && + line.toChar < lastRun.text.length + ) { + return; + } + } + + const lastTextRun = this.findLastTextRun(runs); + + const mark = this.doc.createElement('span'); + mark.classList.add('superdoc-formatting-paragraph-mark'); + mark.setAttribute('aria-hidden', 'true'); + mark.textContent = '¶'; + + const run = lastTextRun?.run; + if (run) { + if (run.fontFamily) { + mark.style.fontFamily = toCssFontFamily(run.fontFamily) ?? run.fontFamily; + } + if (typeof run.fontSize === 'number') { + mark.style.fontSize = `${run.fontSize}px`; + } + if (run.bold) { + mark.style.fontWeight = 'bold'; + } + if (run.italic) { + mark.style.fontStyle = 'italic'; + } + if (run.letterSpacing != null) { + mark.style.letterSpacing = `${run.letterSpacing}px`; + } + } + mark.style.lineHeight = `${line.lineHeight}px`; + + const lineWidth = line.naturalWidth ?? line.width ?? 0; + const alignmentSlack = Math.max(0, availableWidth - lineWidth); + const textAlign = lineEl.style.textAlign; + const alignmentOffset = + !hasExplicitPositioning && textAlign === 'center' + ? alignmentSlack / 2 + : !hasExplicitPositioning && textAlign === 'right' + ? alignmentSlack + : 0; + const isRtl = lineEl.dir === 'rtl' || lineEl.style.direction === 'rtl'; + const visualTextEndOffset = isRtl ? alignmentOffset : alignmentOffset + lineWidth; + mark.style.left = `${Math.max(0, leftOffsetPx + visualTextEndOffset)}px`; + lineEl.appendChild(mark); + } + + private resolveResolvedListParagraphMarkOffset( + marker: ResolvedListMarkerItem | undefined, + markerTextWidth: number | undefined, + fallbackOffset: number | undefined, + ): number | undefined { + if (typeof fallbackOffset === 'number' && Number.isFinite(fallbackOffset) && fallbackOffset > 0) { + return fallbackOffset; + } + if (!marker || marker.vanish) { + return fallbackOffset; + } + + const paddingLeft = Number.isFinite(marker.firstLinePaddingLeftPx) ? marker.firstLinePaddingLeftPx : 0; + const suffixWidth = marker.suffix !== 'nothing' && Number.isFinite(marker.suffixWidthPx) ? marker.suffixWidthPx : 0; + + if (marker.justification === 'left') { + const markerWidth = + typeof markerTextWidth === 'number' && Number.isFinite(markerTextWidth) && markerTextWidth > 0 + ? markerTextWidth + : 0; + return paddingLeft + markerWidth + suffixWidth; + } + + const centerPadding = + marker.justification === 'center' && Number.isFinite(marker.centerPaddingAdjustPx) + ? (marker.centerPaddingAdjustPx ?? 0) + : 0; + return paddingLeft + centerPadding + suffixWidth; + } + private renderRun( run: Run, context: FragmentRenderContext, @@ -5367,7 +5544,7 @@ export class DomPainter { const isActiveLink = !!(linkData && !linkData.blocked && linkData.href); const elem = isActiveLink ? this.doc.createElement('a') : this.doc.createElement('span'); const text = resolveRunText(run, context); - elem.textContent = text; + this.setTextContentWithFormattingSpaceMarks(elem, text); if (linkData?.dataset) { applyLinkDataset(elem, linkData.dataset); @@ -5984,6 +6161,7 @@ export class DomPainter { * @param preExpandedRuns - Pre-computed result of expandRunsForInlineNewlines; pass when rendering multiple lines of the same paragraph to avoid recomputing per line * @param resolvedListTextStartPx - Optional canonical text-start override for list first lines * @param indentOffsetOverride - When defined, used instead of re-deriving indentOffset from block attrs in the segment positioning path + * @param paragraphMarkLeftOffsetOverride - Optional text-start override for positioning presentation-only paragraph marks * @returns The rendered line element */ private renderLine( @@ -5996,6 +6174,7 @@ export class DomPainter { preExpandedRuns?: Run[], resolvedListTextStartPx?: number, indentOffsetOverride?: number, + paragraphMarkLeftOffsetOverride?: number, ): HTMLElement { if (!this.doc) { throw new Error('DomPainter: document is not available'); @@ -6267,6 +6446,35 @@ export class DomPainter { spaceCount, shouldJustify: justifyShouldApply, }); + const resolveLineIndentOffset = (): number => { + if (indentOffsetOverride != null) { + return indentOffsetOverride; + } + + const paraIndent = (block.attrs as ParagraphAttrs | undefined)?.indent; + const indentLeft = paraIndent?.left ?? 0; + const firstLine = paraIndent?.firstLine ?? 0; + const hanging = paraIndent?.hanging ?? 0; + const isFirstLineOfPara = lineIndex === 0 || lineIndex === undefined; + const firstLineOffsetForCumX = isFirstLineOfPara ? firstLine - hanging : 0; + const wordLayoutValue = (block.attrs as ParagraphAttrs | undefined)?.wordLayout; + const wordLayout = isMinimalWordLayout(wordLayoutValue) ? wordLayoutValue : undefined; + const isListParagraph = Boolean(wordLayout?.marker); + const fallbackListTextStartPx = + typeof wordLayout?.marker?.textStartX === 'number' && Number.isFinite(wordLayout.marker.textStartX) + ? wordLayout.marker.textStartX + : typeof wordLayout?.textStartPx === 'number' && Number.isFinite(wordLayout.textStartPx) + ? wordLayout.textStartPx + : undefined; + const listIndentOffset = isFirstLineOfPara + ? (resolvedListTextStartPx ?? fallbackListTextStartPx ?? indentLeft) + : indentLeft; + + return isListParagraph ? listIndentOffset : indentLeft + firstLineOffsetForCumX; + }; + const lineTextStartOffsetPx = + paragraphMarkLeftOffsetOverride != null ? paragraphMarkLeftOffsetOverride : resolveLineIndentOffset(); + const paragraphMarkLeftOffsetPx = lineTextStartOffsetPx; if (spacingPerSpace !== 0) { // Each rendered line is its own block; relying on text-align-last is brittle, so we use word-spacing. @@ -6281,32 +6489,9 @@ export class DomPainter { // // The segment x positions from layout are relative to the content area (left margin = 0). // We need to add the paragraph indent to ALL positions (both explicit and calculated). - let indentOffset: number; - if (indentOffsetOverride != null) { - // Resolved path: indentOffset was pre-computed by the resolver. - indentOffset = indentOffsetOverride; - } else { - // Legacy path: derive from block attrs. - const paraIndent = (block.attrs as ParagraphAttrs | undefined)?.indent; - const indentLeft = paraIndent?.left ?? 0; - const firstLine = paraIndent?.firstLine ?? 0; - const hanging = paraIndent?.hanging ?? 0; - const isFirstLineOfPara = lineIndex === 0 || lineIndex === undefined; - const firstLineOffsetForCumX = isFirstLineOfPara ? firstLine - hanging : 0; - const wordLayoutValue = (block.attrs as ParagraphAttrs | undefined)?.wordLayout; - const wordLayout = isMinimalWordLayout(wordLayoutValue) ? wordLayoutValue : undefined; - const isListParagraph = Boolean(wordLayout?.marker); - const fallbackListTextStartPx = - typeof wordLayout?.marker?.textStartX === 'number' && Number.isFinite(wordLayout.marker.textStartX) - ? wordLayout.marker.textStartX - : typeof wordLayout?.textStartPx === 'number' && Number.isFinite(wordLayout.textStartPx) - ? wordLayout.textStartPx - : undefined; - const listIndentOffset = isFirstLineOfPara - ? (resolvedListTextStartPx ?? fallbackListTextStartPx ?? indentLeft) - : indentLeft; - indentOffset = isListParagraph ? listIndentOffset : indentLeft + firstLineOffsetForCumX; - } + // Segment x positions and paragraph marks both need the visual text start, + // including list marker/suffix space when the resolved layout provides it. + const indentOffset = lineTextStartOffsetPx; let cumulativeX = 0; // Start at 0, we'll add indentOffset when positioning const segments = line.segments!; @@ -6702,6 +6887,15 @@ export class DomPainter { closeCurrentWrapper(); } + this.appendFormattingParagraphMark( + el, + line, + expandedBlock.runs, + paragraphMarkLeftOffsetPx, + availableWidth, + hasExplicitPositioning ?? false, + ); + // Post-process: Apply tooltip accessibility for any links with pending tooltips // This must happen after elements are in the DOM so aria-describedby can reference siblings const anchors = el.querySelectorAll('a[href]'); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 909efe5a98..503c445778 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -315,6 +315,84 @@ const TRACK_CHANGE_STYLES = ` } `; +const FORMATTING_MARKS_STYLES = ` +.superdoc-formatting-space-mark, +.superdoc-marker-suffix-space { + position: relative; +} + +.superdoc-formatting-space-mark { + white-space: pre; +} + +.superdoc-layout.superdoc-show-formatting-marks .superdoc-tab { + position: relative; + visibility: visible !important; +} + +.superdoc-layout.superdoc-show-formatting-marks .superdoc-tab::after { + content: "→"; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: var(--sd-formatting-mark-color, var(--sd-ui-action, currentColor)); + font-size: 0.75em; + line-height: 1; + pointer-events: none; +} + +.superdoc-layout.superdoc-show-formatting-marks [dir="rtl"] .superdoc-tab::after { + content: "←"; +} + +.superdoc-layout.superdoc-show-formatting-marks .superdoc-formatting-space-mark::after, +.superdoc-layout.superdoc-show-formatting-marks .superdoc-marker-suffix-space::after { + content: "·"; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: var(--sd-formatting-mark-color, var(--sd-ui-action, currentColor)); + font-size: 0.75em; + line-height: 1; + pointer-events: none; +} + +.superdoc-formatting-paragraph-mark { + display: none; + position: absolute; + top: 0; + transform: translateX(var(--sd-formatting-paragraph-mark-gap, 0.2em)); + color: var(--sd-formatting-mark-color, var(--sd-ui-action, currentColor)); + pointer-events: none; + user-select: none; + white-space: pre; + z-index: 2; +} + +.superdoc-layout.superdoc-show-formatting-marks .superdoc-formatting-paragraph-mark { + display: inline; +} + +.superdoc-layout.superdoc-show-formatting-marks [dir="rtl"] .superdoc-formatting-paragraph-mark { + transform: translateX(calc(-100% - var(--sd-formatting-paragraph-mark-gap, 0.2em))); +} + +@media print { + .superdoc-layout.superdoc-show-formatting-marks .superdoc-tab::after, + .superdoc-layout.superdoc-show-formatting-marks .superdoc-formatting-space-mark::after, + .superdoc-layout.superdoc-show-formatting-marks .superdoc-marker-suffix-space::after { + content: ""; + display: none; + } + + .superdoc-layout.superdoc-show-formatting-marks .superdoc-formatting-paragraph-mark { + display: none; + } +} +`; + /** * SDT Container Styles - Styling for document sections and structured content containers. * @@ -741,6 +819,7 @@ menclose::after { let printStylesInjected = false; let linkStylesInjected = false; let trackChangeStylesInjected = false; +let formattingMarksStylesInjected = false; let sdtContainerStylesInjected = false; let fieldAnnotationStylesInjected = false; let imageSelectionStylesInjected = false; @@ -773,6 +852,15 @@ export const ensureTrackChangeStyles = (doc: Document | null | undefined) => { trackChangeStylesInjected = true; }; +export const ensureFormattingMarksStyles = (doc: Document | null | undefined) => { + if (formattingMarksStylesInjected || !doc) return; + const styleEl = doc.createElement('style'); + styleEl.setAttribute('data-superdoc-formatting-marks-styles', 'true'); + styleEl.textContent = FORMATTING_MARKS_STYLES; + doc.head?.appendChild(styleEl); + formattingMarksStylesInjected = true; +}; + export const ensureSdtContainerStyles = (doc: Document | null | undefined) => { if (sdtContainerStylesInjected || !doc) return; const styleEl = doc.createElement('style'); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index b8a44538dd..bbb9650371 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -87,6 +87,45 @@ describe('renderTableCell', () => { }, }); + it('uses an end-of-cell mark for the final paragraph in a table cell', () => { + const secondParagraphBlock: ParagraphBlock = { + kind: 'paragraph', + id: 'para-2', + runs: [{ text: '2', fontFamily: 'Arial', fontSize: 16 }], + }; + const secondParagraphMeasure: ParagraphMeasure = { + ...paragraphMeasure, + totalHeight: 20, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + ...baseCellMeasure, + blocks: [paragraphMeasure, secondParagraphMeasure], + }, + cell: { + ...baseCell, + blocks: [paragraphBlock, secondParagraphBlock], + }, + renderLine: () => { + const line = doc.createElement('div'); + const mark = doc.createElement('span'); + mark.classList.add('superdoc-formatting-paragraph-mark'); + mark.textContent = '¶'; + line.appendChild(mark); + return line; + }, + }); + + const marks = cellElement.querySelectorAll('.superdoc-formatting-paragraph-mark'); + expect(marks).toHaveLength(2); + expect(marks[0].textContent).toBe('¶'); + expect(marks[0].classList.contains('superdoc-formatting-cell-mark')).toBe(false); + expect(marks[1].textContent).toBe('¤'); + expect(marks[1].classList.contains('superdoc-formatting-cell-mark')).toBe(true); + }); + it('centers content when verticalAlign is center', () => { const { cellElement } = renderTableCell({ ...createBaseDeps(), @@ -1380,6 +1419,10 @@ describe('renderTableCell', () => { // Left-justified markers stay inline (position: relative on container span) const markerContainer = markerEl.parentElement as HTMLElement; expect(markerContainer.style.position).toBe('relative'); + + const tabEl = lineEl.querySelector('.superdoc-tab') as HTMLElement; + expect(tabEl.classList.contains('superdoc-marker-suffix-tab')).toBe(true); + expect(tabEl.style.fontSize).toBe('14px'); }); it('should render numbered list marker with correct text', () => { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 754f048458..b3040178f0 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -394,15 +394,21 @@ function renderListMarker(params: MarkerRenderParams): void { const suffixType = markerLayout?.suffix ?? 'tab'; if (suffixType === 'tab') { const tabEl = doc.createElement('span'); - tabEl.className = 'superdoc-tab'; + tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); tabEl.innerHTML = ' '; tabEl.style.display = 'inline-block'; + if (markerLayout?.run?.fontSize != null) { + tabEl.style.fontSize = `${markerLayout.run.fontSize}px`; + } tabEl.style.wordSpacing = '0px'; tabEl.style.width = `${listTabWidth}px`; lineEl.prepend(tabEl); } else if (suffixType === 'space') { const spaceEl = doc.createElement('span'); spaceEl.classList.add('superdoc-marker-suffix-space'); + if (markerLayout?.run?.fontSize != null) { + spaceEl.style.fontSize = `${markerLayout.run.fontSize}px`; + } spaceEl.style.wordSpacing = '0px'; spaceEl.textContent = '\u00A0'; lineEl.prepend(spaceEl); @@ -519,6 +525,14 @@ const applyInlineStyles = (el: HTMLElement, styles: Partial }); }; +const convertParagraphMarkToCellMark = (lineEl: HTMLElement): void => { + const mark = lineEl.querySelector('.superdoc-formatting-paragraph-mark'); + if (!mark) return; + + mark.classList.add('superdoc-formatting-cell-mark'); + mark.textContent = '¤'; +}; + /** * Parameters for rendering a nested table inside a table cell. * @@ -1298,6 +1312,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const paragraphMeasure = blockMeasure as ParagraphMeasure; const lines = paragraphMeasure.lines; const blockLineCount = lines?.length || 0; + const isLastBlockInCell = i === Math.min(blockMeasures.length, cellBlocks.length) - 1; /** * Extract Word layout information from paragraph attributes. @@ -1411,6 +1426,9 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen isLastLine, lineIdx === 0 && localStartLine === 0 ? listFirstLineTextStartPx : undefined, ); + if (isLastBlockInCell && isLastLine) { + convertParagraphMarkToCellMark(lineEl); + } lineEl.style.paddingLeft = ''; lineEl.style.paddingRight = ''; lineEl.style.textIndent = ''; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.js index ab5f4d14f1..ac86df9e7b 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/constants.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.js @@ -71,6 +71,7 @@ export const HEADLESS_ITEM_MAP = { acceptTrackedChangeBySelection: 'track-changes-accept-selection', rejectTrackedChangeOnSelection: 'track-changes-reject-selection', ruler: 'ruler', + formattingMarks: 'formatting-marks', zoom: 'zoom', documentMode: 'document-mode', link: 'link', diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js index 41b7e50418..b4b4fbbff0 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js @@ -930,6 +930,19 @@ export const makeDefaultItems = ({ }, }); + const formattingMarks = useToolbarItem({ + type: 'button', + name: 'formattingMarks', + command: 'toggleFormattingMarks', + allowWithoutEditor: true, + icon: toolbarIcons.formattingMarks, + active: false, + tooltip: toolbarTexts.formattingMarks, + attributes: { + ariaLabel: 'Formatting marks', + }, + }); + const selectedLinkedStyle = ref(null); const linkedStyles = useToolbarItem({ type: 'dropdown', @@ -1027,7 +1040,7 @@ export const makeDefaultItems = ({ const stickyItemsWidth = 120; const toolbarPadding = 32; - const itemsToHideXL = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler']; + const itemsToHideXL = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler', 'formattingMarks']; const itemsToHideSM = ['zoom', 'fontFamily', 'fontSize', 'redo']; const shouldUseLgCompactStyles = availableWidth <= RESPONSIVE_BREAKPOINTS.lg; @@ -1078,6 +1091,7 @@ export const makeDefaultItems = ({ linkedStyles, separator, ruler, + formattingMarks, copyFormat, clearFormatting, aiButton, @@ -1099,7 +1113,7 @@ export const makeDefaultItems = ({ const getLinkedStylesIndex = toolbarItems.findIndex((item) => item.name.value === 'linkedStyles'); toolbarItems.splice(getLinkedStylesIndex - 1, 2); - const filterItems = ['ruler', 'zoom', 'undo', 'redo']; + const filterItems = ['ruler', 'formattingMarks', 'zoom', 'undo', 'redo']; toolbarItems = toolbarItems.filter((item) => !filterItems.includes(item.name.value)); } diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 508af27520..a219406246 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -691,6 +691,9 @@ export class SuperToolbar extends EventEmitter { if (!this.activeEditor || currentMode === 'viewing') { this.#deactivateAll(); + this.toolbarItems.forEach((item) => { + if (item.allowWithoutEditor?.value) this.#applyHeadlessState(item); + }); return; } diff --git a/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js b/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js index 8b8caf0f08..4b00123b67 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js @@ -51,6 +51,7 @@ import scissorsIconSvg from '@superdoc/common/icons/scissors-solid.svg?raw'; import copyIconSvg from '@superdoc/common/icons/copy-solid.svg?raw'; import pasteIconSvg from '@superdoc/common/icons/paste-solid.svg?raw'; import strikethroughSvg from '@superdoc/common/icons/strikethrough.svg?raw'; +import paragraphIconSvg from '@superdoc/common/icons/paragraph-solid.svg?raw'; export const toolbarIcons = { undo: rotateLeftIconSvg, @@ -115,4 +116,5 @@ export const toolbarIcons = { copy: copyIconSvg, paste: pasteIconSvg, strikethrough: strikethroughSvg, + formattingMarks: paragraphIconSvg, }; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/toolbarTexts.js b/packages/super-editor/src/editors/v1/components/toolbar/toolbarTexts.js index 82caab2244..904365fb2e 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/toolbarTexts.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/toolbarTexts.js @@ -42,6 +42,7 @@ export const toolbarTexts = { lineHeight: 'Line height', formatText: 'Format text', ruler: 'Show or hide ruler', + formattingMarks: 'Show or hide formatting marks', pageBreak: 'Insert page break', documentEditingMode: 'Editing', documentSuggestingMode: 'Suggesting', diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index c46f67f3ca..266fa9fcf4 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -633,6 +633,7 @@ export class PresentationEditor extends EventEmitter { enableCommentsInViewing: options.layoutEngineOptions?.enableCommentsInViewing, presence: validatedPresence, showBookmarks: options.layoutEngineOptions?.showBookmarks ?? false, + showFormattingMarks: options.layoutEngineOptions?.showFormattingMarks ?? false, }; this.#trackedChangesOverrides = options.layoutEngineOptions?.trackedChanges; @@ -2956,6 +2957,54 @@ export class PresentationEditor extends EventEmitter { this.#scheduleRerender(); } + setShowFormattingMarks(showFormattingMarks: boolean): void { + const next = !!showFormattingMarks; + if (this.#layoutOptions.showFormattingMarks === next) return; + this.#layoutOptions.showFormattingMarks = next; + this.#painterAdapter.setShowFormattingMarks(next); + if (!this.#repaintCurrentLayout()) { + this.#pendingDocChange = true; + this.#scheduleRerender(); + } + } + + #repaintCurrentLayout(): boolean { + const layout = this.#layoutState.layout; + if (!layout) return false; + + const blocks = this.#layoutLookupBlocks.length > 0 ? this.#layoutLookupBlocks : this.#layoutState.blocks; + const measures = this.#layoutLookupMeasures.length > 0 ? this.#layoutLookupMeasures : this.#layoutState.measures; + if (blocks.length === 0 || blocks.length !== measures.length) return false; + + const resolvedLayout = resolveLayout({ + layout, + flowMode: this.#layoutOptions.flowMode ?? 'paginated', + blocks, + measures, + }); + + const isSemanticFlow = this.#layoutOptions.flowMode === 'semantic'; + this.#ensurePainter(); + if (!isSemanticFlow) { + this.#painterAdapter.setProviders( + this.#headerFooterSession?.headerDecorationProvider, + this.#headerFooterSession?.footerDecorationProvider, + ); + } + + this.#domIndexObserverManager?.pause(); + try { + this.#painterAdapter.paint({ resolvedLayout }, this.#painterHost); + this.#refreshEditorDomAugmentations(); + } finally { + this.#domIndexObserverManager?.resume(); + } + this.#revalidateScrollContainer(); + this.#updatePermissionOverlay(); + this.#applyZoom(); + return true; + } + /** * Convert a viewport coordinate into a document hit using the current layout. */ @@ -6286,6 +6335,7 @@ export class PresentationEditor extends EventEmitter { footerProvider: this.#headerFooterSession?.footerDecorationProvider, ruler: this.#layoutOptions.ruler, pageGap: this.#layoutState.layout?.pageGap ?? effectiveGap, + showFormattingMarks: this.#layoutOptions.showFormattingMarks ?? false, }); // Pass the current zoom so virtualization accounts for the CSS transform scale diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.test.ts index b9fc4313aa..a3703fc720 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.test.ts @@ -11,6 +11,7 @@ const { mockCreateDomPainter, mockPainterHandle } = vi.hoisted(() => { setZoom: vi.fn(), setScrollContainer: vi.fn(), setVirtualizationPins: vi.fn(), + setShowFormattingMarks: vi.fn(), onScroll: vi.fn(), getMountedPageIndices: vi.fn(() => []), }; @@ -40,6 +41,7 @@ describe('PresentationPainterAdapter', () => { adapter.setZoom(1.5); adapter.setScrollContainer(scrollContainer); adapter.setVirtualizationPins([3, 1, 3, 2]); + adapter.setShowFormattingMarks(true); adapter.ensurePainter({}); @@ -47,6 +49,7 @@ describe('PresentationPainterAdapter', () => { expect(mockPainterHandle.setZoom).toHaveBeenCalledWith(1.5); expect(mockPainterHandle.setScrollContainer).toHaveBeenCalledWith(scrollContainer); expect(mockPainterHandle.setVirtualizationPins).toHaveBeenCalledWith([1, 2, 3]); + expect(mockPainterHandle.setShowFormattingMarks).toHaveBeenCalledWith(true); }); it('deduplicates equivalent virtualization pin updates', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.ts index 645896aad5..e97abced26 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.ts @@ -45,6 +45,7 @@ export class PresentationPainterAdapter { #zoom = 1; #scrollContainer: HTMLElement | null = null; #virtualizationPins: number[] = []; + #showFormattingMarks = false; // ── Lifecycle ─────────────────────────────────────────────────────── @@ -54,8 +55,10 @@ export class PresentationPainterAdapter { ensurePainter(options: DomPainterOptions): void { if (!this.#painter) { + this.#showFormattingMarks = Boolean(options.showFormattingMarks ?? this.#showFormattingMarks); this.#painter = createDomPainter({ ...options, + showFormattingMarks: this.#showFormattingMarks, onPaintSnapshot: (snapshot) => { this.#lastPaintSnapshot = snapshot; this.#paintIndex.update(snapshot); @@ -87,6 +90,13 @@ export class PresentationPainterAdapter { this.#applyProviders(); } + setShowFormattingMarks(showFormattingMarks: boolean): void { + const next = Boolean(showFormattingMarks); + if (this.#showFormattingMarks === next) return; + this.#showFormattingMarks = next; + this.#applyShowFormattingMarks(); + } + // ── Zoom / scroll ────────────────────────────────────────────────── setZoom(zoom: number): void { @@ -160,6 +170,7 @@ export class PresentationPainterAdapter { this.#applyZoom(); this.#applyScrollContainer(); this.#applyVirtualizationPins(); + this.#applyShowFormattingMarks(); } #applyProviders(): void { @@ -177,4 +188,8 @@ export class PresentationPainterAdapter { #applyVirtualizationPins(): void { this.#painter?.setVirtualizationPins(this.#virtualizationPins); } + + #applyShowFormattingMarks(): void { + this.#painter?.setShowFormattingMarks(this.#showFormattingMarks); + } } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts index 8ad6c28777..1731154f0e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts @@ -573,6 +573,52 @@ describe('computeSelectionRectsFromDom', () => { document.createRange = originalCreateRange; }); + it('sets range boundaries across descendant text nodes inside one PM-mapped span', () => { + painterHost.innerHTML = ` +
+
+ testing 123 +
+
+ `; + + const layout = createMockLayout([{ pmStart: 2, pmEnd: 13 }]); + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const spanEl = painterHost.querySelector('span[data-pm-start]') as HTMLElement; + const textNodes = Array.from(spanEl.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE) as Text[]; + const spaceTextNode = spanEl.querySelector('.superdoc-formatting-space-mark')?.firstChild as Text; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + + const mockRange = { + setStart: vi.fn(), + setEnd: vi.fn(), + getClientRects: vi.fn(() => [createRect(10, 20, 100, 16)]), + } as unknown as Range; + + const originalCreateRange = document.createRange; + document.createRange = vi.fn(() => mockRange); + + const options = createOptions(layout); + const rects = computeSelectionRectsFromDom(options, 2, 13); + + expect(rects).not.toBe(null); + expect(mockRange.setStart).toHaveBeenCalledWith(textNodes[0], 0); + expect(mockRange.setEnd).toHaveBeenCalledWith(textNodes[1], 3); + + vi.mocked(mockRange.setStart).mockClear(); + vi.mocked(mockRange.setEnd).mockClear(); + + const rectsThroughSpace = computeSelectionRectsFromDom(options, 9, 10); + expect(rectsThroughSpace).not.toBe(null); + expect(mockRange.setStart).toHaveBeenCalledWith(spaceTextNode, 0); + expect(mockRange.setEnd).toHaveBeenCalledWith(spaceTextNode, 1); + + document.createRange = originalCreateRange; + }); + it('returns empty array for collapsed selection (from === to)', () => { painterHost.innerHTML = `
@@ -1798,6 +1844,101 @@ describe('computeDomCaretPageLocal', () => { document.createRange = originalCreateRange; }); + + it('maps PM positions across descendant text nodes inside one PM-mapped span', () => { + painterHost.innerHTML = ` +
+
+ testing 123 +
+
+ `; + + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const lineEl = painterHost.querySelector('.superdoc-line') as HTMLElement; + const spanEl = painterHost.querySelector('span[data-pm-start]') as HTMLElement; + const textNodes = Array.from(spanEl.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE) as Text[]; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + lineEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 150, 16)); + spanEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 120, 16)); + + const mockRange = { + setStart: vi.fn(), + setEnd: vi.fn(), + getBoundingClientRect: vi.fn(() => createRect(90, 20, 0, 16)), + } as unknown as Range; + + const originalCreateRange = document.createRange; + document.createRange = vi.fn(() => mockRange); + + const options = createCaretOptions(); + const caret = computeDomCaretPageLocal(options, 12); + + expect(caret).not.toBe(null); + expect(mockRange.setStart).toHaveBeenCalledWith(textNodes[1], 2); + expect(mockRange.setEnd).toHaveBeenCalledWith(textNodes[1], 2); + + document.createRange = originalCreateRange; + }); + + it('maps paragraph-boundary positions to the correct visual line', () => { + painterHost.innerHTML = ` +
+
+ testing 123 +
+
+ 3123122313 +
+
+ `; + + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const lineEls = Array.from(painterHost.querySelectorAll('.superdoc-line')) as HTMLElement[]; + const spanEls = Array.from(painterHost.querySelectorAll('span[data-pm-start]')) as HTMLElement[]; + const firstTextNodes = Array.from(spanEls[0].childNodes).filter( + (node) => node.nodeType === Node.TEXT_NODE, + ) as Text[]; + const secondTextNode = spanEls[1].firstChild as Text; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + lineEls[0].getBoundingClientRect = vi.fn(() => createRect(10, 20, 150, 16)); + lineEls[1].getBoundingClientRect = vi.fn(() => createRect(10, 40, 150, 16)); + spanEls[0].getBoundingClientRect = vi.fn(() => createRect(10, 20, 120, 16)); + spanEls[1].getBoundingClientRect = vi.fn(() => createRect(10, 40, 100, 16)); + + const firstLineRange = { + setStart: vi.fn(), + setEnd: vi.fn(), + getBoundingClientRect: vi.fn(() => createRect(90, 20, 0, 16)), + } as unknown as Range; + const secondLineRange = { + setStart: vi.fn(), + setEnd: vi.fn(), + getBoundingClientRect: vi.fn(() => createRect(10, 40, 0, 16)), + } as unknown as Range; + + const originalCreateRange = document.createRange; + document.createRange = vi.fn().mockReturnValueOnce(firstLineRange).mockReturnValueOnce(secondLineRange); + + const options = createCaretOptions(); + const firstLineCaret = computeDomCaretPageLocal(options, 13); + const secondLineCaret = computeDomCaretPageLocal(options, 17); + + expect(firstLineCaret).toMatchObject({ pageIndex: 0, y: 20 }); + expect(firstLineRange.setStart).toHaveBeenCalledWith(firstTextNodes[1], 3); + expect(firstLineRange.setEnd).toHaveBeenCalledWith(firstTextNodes[1], 3); + expect(secondLineCaret).toMatchObject({ pageIndex: 0, y: 40 }); + expect(secondLineRange.setStart).toHaveBeenCalledWith(secondTextNode, 0); + expect(secondLineRange.setEnd).toHaveBeenCalledWith(secondTextNode, 0); + + document.createRange = originalCreateRange; + }); }); describe('page index extraction', () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts index 2d5fbe5ea1..c8a7345f99 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts @@ -124,6 +124,7 @@ vi.mock('@superdoc/painter-dom', () => ({ getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), DOM_CLASS_NAMES: { PAGE: 'superdoc-page', diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts index 954247469a..6a1bb1943d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts @@ -141,6 +141,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockEditorConverterStore: converterStore, mockEditorOverlayManager: vi.fn().mockImplementation(() => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts index 3b70896003..8d77493bad 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts @@ -65,6 +65,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockEditorConverterStore: converterStore, mockEditorOverlayManager: vi.fn().mockImplementation(() => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts index edc977cfd5..80565667db 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts @@ -78,6 +78,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockEditorConverterStore: converterStore, mockEditorOverlayManager: vi.fn().mockImplementation(() => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts index eb8b69045e..d8de2e2331 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts @@ -112,6 +112,7 @@ vi.mock('@superdoc/painter-dom', () => ({ getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), DOM_CLASS_NAMES: { PAGE: '', FRAGMENT: '', LINE: '', INLINE_SDT_WRAPPER: '', BLOCK_SDT: '', DOCUMENT_SECTION: '' }, })); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts index 43952f86e3..d5353572c6 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts @@ -66,6 +66,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockMeasureBlock: vi.fn(() => ({ width: 100, height: 100 })), mockEditorConverterStore: converterStore, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts index 44b8833b16..934d3a92d1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts @@ -51,6 +51,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockEditorConverterStore: converterStore, mockEditorOverlayManager: vi.fn().mockImplementation(() => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts index b75b0eb138..c7fd02c315 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts @@ -66,6 +66,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockMeasureBlock: vi.fn(() => ({ width: 100, height: 100 })), mockEditorConverterStore: converterStore, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts index 71169dbb0d..05787f77d2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts @@ -86,6 +86,7 @@ vi.mock('@superdoc/painter-dom', () => ({ getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), DOM_CLASS_NAMES: { PAGE: '', FRAGMENT: '', LINE: '', INLINE_SDT_WRAPPER: '', BLOCK_SDT: '', DOCUMENT_SECTION: '' }, })); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts index 08aea2d32f..76c3e7b0f3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts @@ -84,6 +84,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockMeasureBlock: vi.fn(() => ({ width: 100, height: 100 })), mockEditorConverterStore: converterStore, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts index 28a2da6a98..26d0ad7da5 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts @@ -66,6 +66,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockMeasureBlock: vi.fn(() => ({ width: 100, height: 100 })), mockEditorConverterStore: converterStore, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 7f64d8f50d..8d4474b90a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -150,6 +150,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), setProviders: vi.fn(), })), mockMeasureBlock: vi.fn(() => ({ width: 100, height: 100 })), diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts index 4020a94542..967984eb46 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts @@ -123,6 +123,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockMeasureBlock: vi.fn(() => ({ width: 100, height: 100 })), mockEditorConverterStore: converterStore, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index d25c2f5c7f..e0ca65cea9 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -174,6 +174,8 @@ export type LayoutEngineOptions = { * Word's behavior. SD-2454. */ showBookmarks?: boolean; + /** Render nonprinting formatting marks such as spaces, tabs, and paragraph marks. */ + showFormattingMarks?: boolean; }; export type PresentationEditorOptions = ConstructorParameters[0] & { diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts index b54e871e5e..9e32dfbe2d 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts @@ -471,11 +471,9 @@ function setDomRangeStart(range: Range, entry: DomPositionIndexEntry, pos: numbe const el = entry.el; const pmStart = entry.pmStart; - const firstChild = el.firstChild; - if (firstChild && firstChild.nodeType === Node.TEXT_NODE) { - const textNode = firstChild as Text; - const charIndex = mapPmPosToCharIndex(pos, pmStart, entry.pmEnd, textNode.length); - range.setStart(textNode, charIndex); + const boundary = resolveTextBoundaryInElement(el, pos, pmStart, entry.pmEnd, 'forward'); + if (boundary) { + range.setStart(boundary.node, boundary.offset); return true; } @@ -505,11 +503,9 @@ function setDomRangeEnd(range: Range, entry: DomPositionIndexEntry, pos: number) const el = entry.el; const pmStart = entry.pmStart; - const firstChild = el.firstChild; - if (firstChild && firstChild.nodeType === Node.TEXT_NODE) { - const textNode = firstChild as Text; - const charIndex = mapPmPosToCharIndex(pos, pmStart, entry.pmEnd, textNode.length); - range.setEnd(textNode, charIndex); + const boundary = resolveTextBoundaryInElement(el, pos, pmStart, entry.pmEnd, 'backward'); + if (boundary) { + range.setEnd(boundary.node, boundary.offset); return true; } @@ -577,8 +573,8 @@ export function computeDomCaretPageLocal( const pageRect = page.getBoundingClientRect(); const zoom = options.zoom; - const textNode = targetEl.firstChild; - if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + const boundary = resolveTextBoundaryInElement(targetEl, pos, entry.pmStart, entry.pmEnd, 'forward'); + if (!boundary) { const elRect = targetEl.getBoundingClientRect(); // For non-text elements (images, math), position caret at the right edge // when pos matches pmEnd (cursor after the element) @@ -590,10 +586,9 @@ export function computeDomCaretPageLocal( }; } - const charIndex = mapPmPosToCharIndex(pos, entry.pmStart, entry.pmEnd, (textNode as Text).length); const range = document.createRange(); - range.setStart(textNode, charIndex); - range.setEnd(textNode, charIndex); + range.setStart(boundary.node, boundary.offset); + range.setEnd(boundary.node, boundary.offset); const rangeRect = range.getBoundingClientRect(); const lineEl = targetEl.closest('.superdoc-line') as HTMLElement | null; const lineRect = lineEl?.getBoundingClientRect() ?? rangeRect; @@ -604,6 +599,72 @@ export function computeDomCaretPageLocal( }; } +type TextBoundaryAffinity = 'forward' | 'backward'; + +function resolveTextBoundaryInElement( + element: HTMLElement, + pos: number, + pmStart: number, + pmEnd: number, + affinity: TextBoundaryAffinity, +): { node: Text; offset: number } | null { + const textLength = element.textContent?.length ?? 0; + if (textLength <= 0) { + return null; + } + + const charIndex = mapPmPosToCharIndex(pos, pmStart, pmEnd, textLength); + return resolveDescendantTextBoundary(element, charIndex, affinity); +} + +function resolveDescendantTextBoundary( + element: HTMLElement, + targetOffset: number, + affinity: TextBoundaryAffinity, +): { node: Text; offset: number } | null { + const doc = element.ownerDocument ?? document; + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let consumed = 0; + let previous: { node: Text; offset: number } | null = null; + let current = walker.nextNode(); + + while (current) { + const textNode = current as Text; + const textLength = textNode.textContent?.length ?? 0; + if (textLength <= 0) { + current = walker.nextNode(); + continue; + } + + const segmentEnd = consumed + textLength; + if (targetOffset < segmentEnd) { + return { + node: textNode, + offset: Math.max(0, targetOffset - consumed), + }; + } + + if (targetOffset === segmentEnd) { + if (affinity === 'backward') { + return { + node: textNode, + offset: textLength, + }; + } + previous = { node: textNode, offset: textLength }; + consumed = segmentEnd; + current = walker.nextNode(); + continue; + } + + previous = { node: textNode, offset: textLength }; + consumed = segmentEnd; + current = walker.nextNode(); + } + + return previous; +} + /** * Maps a ProseMirror position to a character index within a text node. * diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.js b/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.js new file mode 100644 index 0000000000..585df7967e --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.js @@ -0,0 +1,114 @@ +import { Plugin, TextSelection } from 'prosemirror-state'; + +function isListParagraph(node) { + return ( + node?.type?.name === 'paragraph' && + node.attrs?.paragraphProperties?.numberingProperties && + node.attrs?.listRendering + ); +} + +function isRtlParagraph(node) { + return ( + node?.attrs?.paragraphProperties?.rightToLeft === true || + node?.attrs?.direction === 'rtl' || + node?.attrs?.rtl === true + ); +} + +function getParagraphContext($pos) { + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth); + if (node.type.name !== 'paragraph') continue; + return { + node, + pos: depth === 0 ? 0 : $pos.before(depth), + start: $pos.start(depth), + end: $pos.end(depth), + }; + } + return null; +} + +function getParagraphTextBounds(paragraph, paragraphStart) { + let first = null; + let last = null; + + paragraph.descendants((node, pos) => { + if (!node.isText || !node.text?.length) return true; + + const from = paragraphStart + pos; + const to = from + node.text.length; + if (first == null || from < first) first = from; + if (last == null || to > last) last = to; + return true; + }); + + return first == null || last == null ? null : { first, last }; +} + +function findAdjacentTextPosition(doc, boundary, direction) { + let target = null; + if (direction < 0) { + doc.nodesBetween(0, boundary, (node, pos) => { + if (node.isText && node.text?.length) { + target = pos + node.text.length; + } + return true; + }); + return target; + } + + doc.nodesBetween(boundary, doc.content.size, (node, pos) => { + if (target != null) return false; + if (node.isText && node.text?.length) { + target = pos; + return false; + } + return true; + }); + return target; +} + +function shouldHandlePlainHorizontalArrow(event) { + return ( + (event.key === 'ArrowLeft' || event.key === 'ArrowRight') && + !event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey + ); +} + +export function createListBoundaryNavigationPlugin() { + return new Plugin({ + props: { + handleKeyDown(view, event) { + if (!shouldHandlePlainHorizontalArrow(event)) return false; + + const { state } = view; + const { selection } = state; + if (!selection.empty) return false; + + const paragraph = getParagraphContext(selection.$from); + if (!paragraph || !isListParagraph(paragraph.node)) return false; + if (isRtlParagraph(paragraph.node)) return false; + + const bounds = getParagraphTextBounds(paragraph.node, paragraph.start); + if (!bounds) return false; + + const direction = event.key === 'ArrowLeft' ? -1 : 1; + const atLeftBoundary = direction < 0 && selection.from <= bounds.first; + const atRightBoundary = direction > 0 && selection.from >= bounds.last; + if (!atLeftBoundary && !atRightBoundary) return false; + + const target = findAdjacentTextPosition(state.doc, direction < 0 ? paragraph.pos : paragraph.end, direction); + if (target == null || target === selection.from) return false; + + event.preventDefault(); + view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, target)).scrollIntoView()); + return true; + }, + }, + }); +} diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js index bab9606e21..ddbb1a1bfa 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js @@ -13,6 +13,7 @@ import { ParagraphNodeView } from './ParagraphNodeView.js'; import { createNumberingPlugin } from './numberingPlugin.js'; import { createLeadingCaretPlugin } from './leadingCaretPlugin.js'; import { createDropcapPlugin } from './dropcapPlugin.js'; +import { createListBoundaryNavigationPlugin } from './listBoundaryNavigationPlugin.js'; import { shouldSkipNodeView } from '../../utils/headless-helpers.js'; import { parseAttrs } from './helpers/parseAttrs.js'; @@ -392,6 +393,12 @@ export const Paragraph = OxmlNode.create({ }, }, }); - return [dropcapPlugin, numberingPlugin, listInputFallbackPlugin, createLeadingCaretPlugin()]; + return [ + dropcapPlugin, + numberingPlugin, + listInputFallbackPlugin, + createLeadingCaretPlugin(), + createListBoundaryNavigationPlugin(), + ]; }, }); diff --git a/packages/super-editor/src/headless-toolbar/README.md b/packages/super-editor/src/headless-toolbar/README.md index c4cc83b906..5cc17c0400 100644 --- a/packages/super-editor/src/headless-toolbar/README.md +++ b/packages/super-editor/src/headless-toolbar/README.md @@ -80,6 +80,7 @@ For commands not covered by `execute()`, you can use `snapshot.context?.target.c | `undo` | none | — | | `redo` | none | — | | `ruler` | none | — | +| `formatting-marks` | none | — | | `zoom` | number, e.g. `125` | current zoom number | | `document-mode` | `'editing'` \| `'suggesting'` \| `'viewing'` | current mode string | | `clear-formatting` | none | — | diff --git a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts index cd588d212f..7dec8a0cfa 100644 --- a/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts +++ b/packages/super-editor/src/headless-toolbar/create-headless-toolbar.test.ts @@ -700,6 +700,38 @@ describe('createHeadlessToolbar', () => { controller.destroy(); }); + it('executes formatting marks through the registry execute path', () => { + const toggleFormattingMarks = vi.fn(); + const superdoc: HeadlessToolbarSuperdocHost = { + activeEditor: { + commands: {}, + doc: {} as any, + isEditable: true, + state: { + selection: { + empty: true, + }, + }, + } as any, + toggleFormattingMarks, + config: { + layoutEngineOptions: { + showFormattingMarks: false, + }, + }, + } as any; + + const controller = createHeadlessToolbar({ + superdoc, + commands: ['formatting-marks'], + }); + + expect(controller.execute?.('formatting-marks')).toBe(true); + expect(toggleFormattingMarks).toHaveBeenCalledTimes(1); + + controller.destroy(); + }); + it('executes zoom through the registry execute path', () => { const setZoom = vi.fn(); const superdoc: HeadlessToolbarSuperdocHost = { diff --git a/packages/super-editor/src/headless-toolbar/helpers/document.ts b/packages/super-editor/src/headless-toolbar/helpers/document.ts index 8f46da8b64..e807b198db 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/document.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/document.ts @@ -89,6 +89,15 @@ export const createRulerStateDeriver = }; }; +export const createFormattingMarksStateDeriver = + () => + ({ superdoc }: { context: ToolbarContext | null; superdoc: Record }): ToolbarCommandState => { + return { + active: Boolean(superdoc?.config?.layoutEngineOptions?.showFormattingMarks), + disabled: typeof superdoc?.toggleFormattingMarks !== 'function', + }; + }; + export const createZoomStateDeriver = () => ({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }): ToolbarCommandState => { @@ -116,6 +125,14 @@ export const createRulerExecute = return true; }; +export const createFormattingMarksExecute = + () => + ({ superdoc }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => { + if (typeof superdoc?.toggleFormattingMarks !== 'function') return false; + superdoc.toggleFormattingMarks(); + return true; + }; + export const createZoomExecute = () => ({ superdoc, payload }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => { diff --git a/packages/super-editor/src/headless-toolbar/subscribe-toolbar-events.ts b/packages/super-editor/src/headless-toolbar/subscribe-toolbar-events.ts index b729ece527..61b1cfeb39 100644 --- a/packages/super-editor/src/headless-toolbar/subscribe-toolbar-events.ts +++ b/packages/super-editor/src/headless-toolbar/subscribe-toolbar-events.ts @@ -9,11 +9,13 @@ const subscribeToSuperdocEvents = ( superdoc.on('editorCreate', onChange); superdoc.on('document-mode-change', onChange); + superdoc.on('formatting-marks-change', onChange); superdoc.on('zoomChange', onChange); return () => { superdoc.off?.('editorCreate', onChange); superdoc.off?.('document-mode-change', onChange); + superdoc.off?.('formatting-marks-change', onChange); superdoc.off?.('zoomChange', onChange); }; }; diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 66beb7b0b6..976c25e234 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -706,6 +706,26 @@ describe('createToolbarRegistry', () => { }); }); + it('activates formatting marks state when formatting marks are enabled in superdoc config', () => { + const registry = createToolbarRegistry(); + const state = registry['formatting-marks']?.state({ + context: null, + superdoc: { + config: { + layoutEngineOptions: { + showFormattingMarks: true, + }, + }, + toggleFormattingMarks: vi.fn(), + }, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + }); + }); + it('derives zoom value from superdoc', () => { const registry = createToolbarRegistry(); const state = registry.zoom?.state({ diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts index a4026e9422..f4988637bd 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts @@ -1,6 +1,8 @@ import { createDocumentModeExecute, createDocumentModeStateDeriver, + createFormattingMarksExecute, + createFormattingMarksStateDeriver, createHistoryStateDeriver, createRulerExecute, createRulerStateDeriver, @@ -154,6 +156,11 @@ export const createToolbarRegistry = (): Partial b export type HeadlessToolbarSuperdocHost = { activeEditor?: Editor | null; + config?: { + layoutEngineOptions?: { + showFormattingMarks?: boolean; + }; + }; + toggleFormattingMarks?: () => void; on?: (event: string, listener: (...args: any[]) => void) => void; off?: (event: string, listener: (...args: any[]) => void) => void; superdocStore?: { diff --git a/packages/super-editor/src/index.types.test.ts b/packages/super-editor/src/index.types.test.ts index f58ed71131..588626bb84 100644 --- a/packages/super-editor/src/index.types.test.ts +++ b/packages/super-editor/src/index.types.test.ts @@ -535,6 +535,7 @@ const { getMountedPageIndices: vi.fn(() => []), onScroll: vi.fn(), setScrollContainer: vi.fn(), + setShowFormattingMarks: vi.fn(), })), mockMeasureBlock: vi.fn(() => ({ width: 100, height: 100 })), mockEditorConverterStore: converterStore, diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 34f2f5cf45..046883882e 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -671,6 +671,96 @@ describe('SuperDoc.vue', () => { expect(surfaceManager.open).not.toHaveBeenCalled(); }); + it('toggles formatting marks from a document-level Ctrl+Shift+8 when focus is inside SuperDoc', async () => { + const hiddenEditorDom = document.createElement('div'); + hiddenEditorDom.className = 'ProseMirror ProseMirror-focused'; + + const superdocStub = createSuperdocStub(); + superdocStub.toggleFormattingMarks = vi.fn(); + superdocStub.activeEditor = { + view: { + dom: hiddenEditorDom, + }, + }; + + await mountComponent(superdocStub); + vi.spyOn(document, 'activeElement', 'get').mockReturnValue(hiddenEditorDom); + + const event = new KeyboardEvent('keydown', { + key: '8', + code: 'Digit8', + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(superdocStub.toggleFormattingMarks).toHaveBeenCalledTimes(1); + }); + + it('does not toggle formatting marks from the document shortcut when focus is outside SuperDoc', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.toggleFormattingMarks = vi.fn(); + + await mountComponent(superdocStub); + vi.spyOn(document, 'activeElement', 'get').mockReturnValue(document.body); + + const event = new KeyboardEvent('keydown', { + key: '8', + code: 'Digit8', + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(superdocStub.toggleFormattingMarks).not.toHaveBeenCalled(); + }); + + it('toggles formatting marks from the SuperDoc container shortcut handler', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.toggleFormattingMarks = vi.fn(); + + const wrapper = await mountComponent(superdocStub); + vi.spyOn(document, 'activeElement', 'get').mockReturnValue(wrapper.element); + + const event = { + key: '*', + code: '', + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + defaultPrevented: false, + preventDefault: vi.fn(function () { + this.defaultPrevented = true; + }), + stopPropagation: vi.fn(), + }; + + wrapper.vm.$.setupState.handleContainerKeydown(event); + + expect(event.defaultPrevented).toBe(true); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(event.stopPropagation).toHaveBeenCalledTimes(1); + expect(superdocStub.toggleFormattingMarks).toHaveBeenCalledTimes(1); + }); + + it('removes the document shortcut listener on unmount', async () => { + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + + const wrapper = await mountComponent(createSuperdocStub()); + wrapper.unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true); + }); + it('forwards configured passwords to SuperEditor options', async () => { const superdocStub = createSuperdocStub(); superdocStub.config.password = 'top-secret'; diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 95a6a1b051..d533608118 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -1219,19 +1219,17 @@ onMounted(() => { if (config && !config.readOnly) { document.addEventListener('mousedown', handleDocumentMouseDown); } - document.addEventListener('keydown', handleFindShortcut, true); + document.addEventListener('keydown', handleDocumentShortcut, true); }); -/** - * Handle Cmd+F / Ctrl+F to open find/replace instead of browser find. - * Use a document-level capture listener because the dev shell and - * presentation-mode bridge do not always leave keyboard focus on a node - * that bubbles through the .superdoc root. - */ function isFindShortcutEvent(e) { return (e.metaKey || e.ctrlKey) && !e.altKey && e.key?.toLowerCase?.() === 'f'; } +function isFormattingMarksShortcutEvent(e) { + return (e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && (e.code === 'Digit8' || e.key === '8' || e.key === '*'); +} + function isFocusInsideSuperDoc() { const root = superdocRoot.value; const activeElement = document.activeElement; @@ -1261,15 +1259,37 @@ function handleFindShortcut(e) { findReplace.open(); } +function handleFormattingMarksShortcut(e) { + if (!isFormattingMarksShortcutEvent(e)) return; + if (!isFocusInsideSuperDoc()) return; + + e.preventDefault(); + e.stopPropagation(); + proxy.$superdoc.toggleFormattingMarks?.(); +} + +/** + * Handle document-level shortcuts before browser or shell handlers. + * Use a capture listener because the dev shell and presentation-mode bridge + * do not always leave keyboard focus on a node that bubbles through the root. + */ +function handleDocumentShortcut(e) { + handleFindShortcut(e); + if (e.defaultPrevented) return; + handleFormattingMarksShortcut(e); +} + function handleContainerKeydown(e) { handleFindShortcut(e); + if (e.defaultPrevented) return; + handleFormattingMarksShortcut(e); } onBeforeUnmount(() => { passwordPrompt.destroy(); findReplace.destroy(); document.removeEventListener('mousedown', handleDocumentMouseDown); - document.removeEventListener('keydown', handleFindShortcut, true); + document.removeEventListener('keydown', handleDocumentShortcut, true); if (selectionUpdateRafId != null) { cancelAnimationFrame(selectionUpdateRafId); selectionUpdateRafId = null; diff --git a/packages/superdoc/src/assets/styles/helpers/variables.css b/packages/superdoc/src/assets/styles/helpers/variables.css index da1d672023..9af24953af 100644 --- a/packages/superdoc/src/assets/styles/helpers/variables.css +++ b/packages/superdoc/src/assets/styles/helpers/variables.css @@ -286,6 +286,8 @@ /* Styles: layout — cascades from semantic tier */ --sd-layout-page-bg: var(--sd-ui-bg); --sd-layout-page-shadow: 0 4px 20px rgba(15, 23, 42, 0.08); + --sd-formatting-mark-color: var(--sd-color-blue-500); + --sd-formatting-paragraph-mark-gap: 0.2em; /* Proofing: underline colors and metrics */ --sd-proofing-spelling-color: #e53e3e; diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 07acea5a78..59d9a729d1 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -1431,6 +1431,36 @@ export class SuperDoc extends EventEmitter { }); } + /** + * Toggle nonprinting formatting marks (spaces, tabs, paragraph marks) in the + * rendered layout. This is a view-only setting and is not exported to DOCX. + * @param {boolean} show + * @returns {void} + */ + setShowFormattingMarks(show = true) { + const nextValue = Boolean(show); + const layoutOptions = (this.config.layoutEngineOptions = this.config.layoutEngineOptions || {}); + if (layoutOptions.showFormattingMarks === nextValue) return; + layoutOptions.showFormattingMarks = nextValue; + + this.superdocStore?.documents?.forEach((doc) => { + const presentationEditor = doc.getPresentationEditor?.(); + presentationEditor?.setShowFormattingMarks?.(nextValue); + }); + + this.emit('formatting-marks-change', { showFormattingMarks: nextValue, superdoc: this }); + this.toolbar?.updateToolbarState?.(); + } + + /** + * Toggle nonprinting formatting marks from their current state. + * @returns {void} + */ + toggleFormattingMarks() { + const currentValue = Boolean(this.config.layoutEngineOptions?.showFormattingMarks); + this.setShowFormattingMarks(!currentValue); + } + /** * Set the document mode. * @param {DocumentMode} type diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index e677783ebc..70ab774045 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -1255,6 +1255,42 @@ describe('SuperDoc core', () => { expect(setShowBookmarks).toHaveBeenLastCalledWith(false); }); + it('propagates setShowFormattingMarks to presentation editors and skips no-op toggles', async () => { + const { superdocStore } = createAppHarness(); + const setShowFormattingMarks = vi.fn(); + const docStub = { + getPresentationEditor: vi.fn(() => ({ setShowFormattingMarks })), + }; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + role: 'editor', + user: { name: 'Jane', email: 'jane@example.com' }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + superdocStore.documents = [docStub]; + + instance.setShowFormattingMarks(true); + expect(instance.config.layoutEngineOptions.showFormattingMarks).toBe(true); + expect(setShowFormattingMarks).toHaveBeenCalledWith(true); + + instance.setShowFormattingMarks(true); + expect(setShowFormattingMarks).toHaveBeenCalledTimes(1); + + instance.setShowFormattingMarks(false); + expect(instance.config.layoutEngineOptions.showFormattingMarks).toBe(false); + expect(setShowFormattingMarks).toHaveBeenLastCalledWith(false); + + instance.toggleFormattingMarks(); + expect(setShowFormattingMarks).toHaveBeenLastCalledWith(true); + }); + it('skips rendering comments list when role is viewer', async () => { createAppHarness(); diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 517ac3643a..451ebf4417 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1234,6 +1234,11 @@ export interface SuperDocLayoutEngineOptions { * at runtime via `superdoc.setShowBookmarks()`. */ showBookmarks?: boolean; + /** + * Whether nonprinting formatting marks are shown in the rendered layout. + * Toggleable at runtime via `superdoc.setShowFormattingMarks()`. + */ + showFormattingMarks?: boolean; } export interface ViewingVisibilityConfig { diff --git a/shared/common/icons/paragraph-solid.svg b/shared/common/icons/paragraph-solid.svg new file mode 100644 index 0000000000..ce12954be2 --- /dev/null +++ b/shared/common/icons/paragraph-solid.svg @@ -0,0 +1 @@ + diff --git a/tests/behavior/tests/lists/list-arrow-boundary-navigation.spec.ts b/tests/behavior/tests/lists/list-arrow-boundary-navigation.spec.ts new file mode 100644 index 0000000000..8578491c92 --- /dev/null +++ b/tests/behavior/tests/lists/list-arrow-boundary-navigation.spec.ts @@ -0,0 +1,70 @@ +import { expect, test, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { createOrderedList, createBulletList } from '../../helpers/lists.js'; + +test.use({ config: { toolbar: 'full', showCaret: true, showSelection: true } }); + +async function enableFormattingMarks(superdoc: SuperDocFixture): Promise { + await superdoc.page.evaluate(() => { + (window as any).superdoc?.setShowFormattingMarks?.(true); + }); + await superdoc.waitForStable(); + await expect(superdoc.page.locator('.superdoc-formatting-paragraph-mark').first()).toBeVisible(); +} + +async function focusHiddenEditor(superdoc: SuperDocFixture): Promise { + await superdoc.page.locator('[contenteditable="true"]').first().focus(); +} + +test.describe('list arrow boundary navigation', () => { + test('skips the marker gap when moving left and right across ordered list items with formatting marks', async ({ + superdoc, + }) => { + const first = 'Numbered item one'; + const second = 'Numbered item two with enough text to be a useful caret target'; + + await createOrderedList(superdoc, [first, second]); + await enableFormattingMarks(superdoc); + + const firstStart = await superdoc.findTextPos(first); + const firstEnd = firstStart + first.length; + const secondStart = await superdoc.findTextPos(second); + + await superdoc.setTextSelection(secondStart); + await focusHiddenEditor(superdoc); + await superdoc.press('ArrowLeft'); + await superdoc.waitForStable(); + await superdoc.assertSelection(firstEnd); + + await superdoc.setTextSelection(firstEnd); + await focusHiddenEditor(superdoc); + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + await superdoc.assertSelection(secondStart); + }); + + test('skips the marker gap for list items inside table cells with formatting marks', async ({ superdoc }) => { + const first = 'Cell item one'; + const second = 'Cell item two'; + + await superdoc.executeCommand('insertTable', { rows: 1, cols: 1, withHeaderRow: false }); + await superdoc.waitForStable(); + await createBulletList(superdoc, [first, second]); + await enableFormattingMarks(superdoc); + + const firstStart = await superdoc.findTextPos(first); + const firstEnd = firstStart + first.length; + const secondStart = await superdoc.findTextPos(second); + + await superdoc.setTextSelection(secondStart); + await focusHiddenEditor(superdoc); + await superdoc.press('ArrowLeft'); + await superdoc.waitForStable(); + await superdoc.assertSelection(firstEnd); + + await superdoc.setTextSelection(firstEnd); + await focusHiddenEditor(superdoc); + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + await superdoc.assertSelection(secondStart); + }); +});