diff --git a/packages/layout-engine/layout-engine/src/anchors.test.ts b/packages/layout-engine/layout-engine/src/anchors.test.ts index b4e80d9501..7b2ed636d2 100644 --- a/packages/layout-engine/layout-engine/src/anchors.test.ts +++ b/packages/layout-engine/layout-engine/src/anchors.test.ts @@ -677,11 +677,14 @@ describe('anchors', () => { const result = collectAnchoredDrawings(blocks, measures); expect(result.size).toBe(0); + expect(result.withoutParagraph).toHaveLength(1); + expect(result.withoutParagraph[0]?.block.id).toBe('img-1'); }); it('should handle empty blocks array', () => { const result = collectAnchoredDrawings([], []); expect(result.size).toBe(0); + expect(result.withoutParagraph).toHaveLength(0); }); it('should handle anchored images with undefined vRelativeFrom (defaults to paragraph)', () => { diff --git a/packages/layout-engine/layout-engine/src/anchors.ts b/packages/layout-engine/layout-engine/src/anchors.ts index d504cfbcd7..62a6042942 100644 --- a/packages/layout-engine/layout-engine/src/anchors.ts +++ b/packages/layout-engine/layout-engine/src/anchors.ts @@ -22,6 +22,14 @@ export type AnchoredDrawing = { measure: ImageMeasure | DrawingMeasure; }; +export type AnchoredDrawingCollection = { + byParagraph: Map; + withoutParagraph: AnchoredDrawing[]; + readonly size: number; + has(index: number): boolean; + get(index: number): AnchoredDrawing[] | undefined; +}; + export type AnchoredTable = { block: TableBlock; measure: TableMeasure; @@ -130,8 +138,9 @@ export function collectPreRegisteredAnchors(blocks: FlowBlock[], measures: Measu * Collect anchored drawings (images/drawings) mapped to their anchor paragraph index. * Map of paragraph block index -> anchored images/drawings associated with that paragraph. */ -export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[]): Map { - const map = new Map(); +export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[]): AnchoredDrawingCollection { + const byParagraph = new Map(); + const withoutParagraph: AnchoredDrawing[] = []; const len = Math.min(blocks.length, measures.length); const paragraphIndexById = buildParagraphIndexById(blocks, len); @@ -159,15 +168,27 @@ export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[] typeof drawingBlock.attrs === 'object' && drawingBlock.attrs ? (drawingBlock.attrs as { anchorParagraphId?: unknown }).anchorParagraphId : undefined; + const anchoredDrawing = { block: drawingBlock, measure: drawingMeasure }; const anchorParaIndex = resolveAnchorParagraphIndex(blocks, len, paragraphIndexById, i, anchorParagraphId); - if (anchorParaIndex == null) continue; // no paragraphs at all + if (anchorParaIndex == null) { + withoutParagraph.push(anchoredDrawing); + continue; + } - const list = map.get(anchorParaIndex) ?? []; - list.push({ block: drawingBlock, measure: drawingMeasure }); - map.set(anchorParaIndex, list); + const list = byParagraph.get(anchorParaIndex) ?? []; + list.push(anchoredDrawing); + byParagraph.set(anchorParaIndex, list); } - return map; + return { + byParagraph, + withoutParagraph, + get size() { + return byParagraph.size; + }, + has: (index: number) => byParagraph.has(index), + get: (index: number) => byParagraph.get(index), + }; } /** diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 7406bbf4fb..66bc8cb3f3 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -15,6 +15,7 @@ import type { ColumnBreakBlock, PageBreakBlock, TableBlock, + TableFragment, TableMeasure, } from '@superdoc/contracts'; import { layoutDocument, layoutHeaderFooter, type LayoutOptions } from './index.js'; @@ -989,6 +990,47 @@ describe('layoutDocument', () => { expect(fragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 15); }); + it('renders paragraphless anchored drawings and floating tables on the same fallback page', () => { + const imageBlock: ImageBlock = { + kind: 'image', + id: 'paragraphless-floating-image', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 24, + offsetV: 36, + }, + wrap: { type: 'Square' }, + }; + const imageMeasure: ImageMeasure = { + kind: 'image', + width: 80, + height: 40, + }; + const floatingTable = makeParagraphlessFloatingTable('paragraphless-floating-table'); + const floatingTableMeasure = makeTableMeasure([220], [60]); + + const layout = layoutDocument([floatingTable, imageBlock], [floatingTableMeasure, imageMeasure], DEFAULT_OPTIONS); + + expect(layout.pages).toHaveLength(1); + + const imageFragment = layout.pages[0].fragments.find( + (candidate) => candidate.kind === 'image' && candidate.blockId === 'paragraphless-floating-image', + ) as ImageFragment | undefined; + const tableFragment = layout.pages[0].fragments.find( + (candidate) => candidate.kind === 'table' && candidate.blockId === 'paragraphless-floating-table', + ) as TableFragment | undefined; + + expect(imageFragment).toBeTruthy(); + expect(tableFragment).toBeTruthy(); + expect(imageFragment?.x).toBe(DEFAULT_OPTIONS.margins!.left + 24); + expect(imageFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 36); + expect(tableFragment?.x).toBe(120); + expect(tableFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 15); + }); + it('renders a floating table after pruning a leading empty page', () => { const leadingPageBreak: PageBreakBlock = { kind: 'pageBreak', @@ -4432,6 +4474,104 @@ describe('layoutHeaderFooter', () => { expect(Array.isArray(fragment.contentMeasures)).toBe(true); expect(fragment.contentMeasures).toHaveLength(1); }); + + it('places paragraphless anchored textboxShape blocks in header/footer layout', () => { + const textboxBlock: FlowBlock = { + kind: 'drawing', + id: 'header-textbox-1', + drawingKind: 'textboxShape', + geometry: { width: 120, height: 40, rotation: 0, flipH: false, flipV: false }, + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: -10, + offsetV: -6, + }, + contentBlocks: [ + { + kind: 'paragraph', + id: 'header-textbox-para-1', + runs: [{ text: 'Header text', pmStart: 1, pmEnd: 12 }], + }, + ], + textInsets: { top: 4, right: 8, bottom: 4, left: 8 }, + }; + const textboxMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'textboxShape', + width: 120, + height: 40, + scale: 1, + naturalWidth: 120, + naturalHeight: 40, + geometry: { width: 120, height: 40, rotation: 0, flipH: false, flipV: false }, + }; + + const layout = layoutHeaderFooter( + [textboxBlock], + [textboxMeasure], + { width: 400, height: 80 }, + 'header', + (_block, _maxWidth) => makeMeasure([16]), + ); + + expect(layout.pages).toHaveLength(1); + const fragment = layout.pages[0].fragments[0] as DrawingFragment; + expect(fragment.kind).toBe('drawing'); + expect(fragment.blockId).toBe('header-textbox-1'); + expect(fragment.isAnchored).toBe(true); + expect(fragment.x).toBe(-10); + expect(fragment.y).toBe(-6); + expect(fragment.contentMeasures).toHaveLength(1); + }); + + it('places paragraphless page-relative behindDoc header images at page offsets', () => { + const imageBlock: FlowBlock = { + kind: 'image', + id: 'header-bg-image', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + vRelativeFrom: 'page', + offsetH: 1387 * (96 / 914400), + offsetV: 254000 * (96 / 914400), + behindDoc: true, + }, + wrap: { type: 'None', behindDoc: true }, + }; + const imageMeasure: ImageMeasure = { + kind: 'image', + width: 7557463 * (96 / 914400), + height: 10723880 * (96 / 914400), + }; + + const layout = layoutHeaderFooter( + [imageBlock], + [imageMeasure], + { + width: 602, + height: 648, + pageWidth: 816, + pageHeight: 1056, + margins: { left: 107, right: 107, top: 72, bottom: 72, header: 36 }, + }, + 'header', + ); + + expect(layout.pages).toHaveLength(1); + const fragment = layout.pages[0].fragments[0] as ImageFragment; + expect(fragment.kind).toBe('image'); + expect(fragment.blockId).toBe('header-bg-image'); + expect(fragment.isAnchored).toBe(true); + expect(fragment.behindDoc).toBe(true); + expect(fragment.zIndex).toBe(0); + expect(fragment.x).toBeCloseTo(0.15, 2); + expect(fragment.y).toBeCloseTo(26.67, 2); + expect(layout.height).toBe(0); + expect(layout.renderHeight).toBeGreaterThan(1000); + }); }); describe('requirePageBoundary edge cases', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 1dec3d599a..9c33b5fbff 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -598,6 +598,7 @@ export type LayoutOptions = { * overlay behavior in paragraph-free header/footer regions. */ allowParagraphlessAnchoredTableFallback?: boolean; + allowParagraphlessAnchoredDrawingFallback?: boolean; /** * Allow body layout to synthesize page 1 when section metadata exists but no * renderable body blocks survive conversion. @@ -945,6 +946,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options let activeColumns = cloneColumnLayout(options.columns); let pendingColumns: ColumnLayout | null = null; const allowParagraphlessAnchoredTableFallback = options.allowParagraphlessAnchoredTableFallback !== false; + const allowParagraphlessAnchoredDrawingFallback = options.allowParagraphlessAnchoredDrawingFallback !== false; const allowSectionBreakOnlyPageFallback = options.allowSectionBreakOnlyPageFallback !== false; // Track active and pending orientation @@ -1984,7 +1986,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options }); // Collect anchored drawings mapped to their anchor paragraphs - const anchoredByParagraph = collectAnchoredDrawings(blocks, measures); + const anchoredDrawings = collectAnchoredDrawings(blocks, measures); + const anchoredByParagraph = anchoredDrawings.byParagraph; + const paragraphlessAnchoredDrawings = anchoredDrawings.withoutParagraph; // PASS 1C: collect anchored/floating tables mapped to their anchor paragraphs. // Tables without any anchor paragraph need explicit fallback placement so // floating-only documents still produce a page and render their content. @@ -2017,6 +2021,36 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options }); }; + const resolveParagraphlessAnchoredDrawingY = ( + block: ImageBlock | DrawingBlock, + measure: ImageMeasure | DrawingMeasure, + state: PageState, + ): number => + resolveAnchoredGraphicY({ + anchor: block.anchor, + objectHeight: measure.height ?? 0, + contentTop: state.topMargin, + contentBottom: state.contentBottom, + pageBottomMargin: state.page.margins?.bottom ?? activeBottomMargin, + preRegisteredFallbackToContentTop: true, + }); + + const resolveParagraphlessAnchoredDrawingX = ( + block: ImageBlock | DrawingBlock, + measure: ImageMeasure | DrawingMeasure, + state: PageState, + ): number => + block.anchor + ? computeAnchorX( + block.anchor, + state.columnIndex, + normalizeColumns(activeColumns, activePageSize.w - (activeLeftMargin + activeRightMargin)), + measure.width, + { left: activeLeftMargin, right: activeRightMargin }, + activePageSize.w, + ) + : columnX(state); + for (const entry of preRegisteredAnchors) { // Ensure first page exists const state = paginator.ensurePage(); @@ -2825,15 +2859,98 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options sectionFirstPageNumbers.clear(); }; + const shouldUseBlankPageFallback = pages.length === 0; + if ( - pages.length === 0 && + shouldUseBlankPageFallback && ((allowParagraphlessAnchoredTableFallback && paragraphlessAnchoredTables.length > 0) || + (allowParagraphlessAnchoredDrawingFallback && paragraphlessAnchoredDrawings.length > 0) || (allowSectionBreakOnlyPageFallback && hasOnlySectionBreakBlocks(blocks))) ) { resetPaginationStateForBlankPageFallback(); } - if (allowParagraphlessAnchoredTableFallback && pages.length === 0 && paragraphlessAnchoredTables.length > 0) { + if ( + allowParagraphlessAnchoredDrawingFallback && + shouldUseBlankPageFallback && + paragraphlessAnchoredDrawings.length > 0 + ) { + const state = paginator.ensurePage(); + + for (const { block, measure } of paragraphlessAnchoredDrawings) { + if (placedAnchoredIds.has(block.id)) continue; + + const anchorX = resolveParagraphlessAnchoredDrawingX(block, measure, state); + const anchorY = resolveParagraphlessAnchoredDrawingY(block, measure, state); + + if (block.kind === 'image' && measure.kind === 'image') { + const pageContentHeight = Math.max(0, state.contentBottom - state.topMargin); + const aspectRatio = measure.width > 0 && measure.height > 0 ? measure.width / measure.height : 1.0; + const minWidth = 20; + const minHeight = minWidth / aspectRatio; + const fragment: ImageFragment = { + kind: 'image', + blockId: block.id, + x: anchorX, + y: anchorY, + width: measure.width, + height: measure.height, + isAnchored: true, + behindDoc: block.anchor?.behindDoc === true, + zIndex: getFragmentZIndex(block), + metadata: { + originalWidth: measure.width, + originalHeight: measure.height, + maxWidth: activePageSize.w - (activeLeftMargin + activeRightMargin), + maxHeight: pageContentHeight, + aspectRatio, + minWidth, + minHeight, + }, + sourceAnchor: block.sourceAnchor, + }; + const attrs = block.attrs as Record | undefined; + if (attrs?.pmStart != null) fragment.pmStart = attrs.pmStart as number; + if (attrs?.pmEnd != null) fragment.pmEnd = attrs.pmEnd as number; + state.page.fragments.push(fragment); + placedAnchoredIds.add(block.id); + continue; + } + + if (block.kind === 'drawing' && measure.kind === 'drawing') { + const contentMeasures = + block.drawingKind === 'textboxShape' && typeof options.remeasureParagraph === 'function' + ? layoutTextboxContent(block, options.remeasureParagraph) + : undefined; + const fragment: DrawingFragment = { + kind: 'drawing', + blockId: block.id, + drawingKind: block.drawingKind, + x: anchorX, + y: anchorY, + width: measure.width, + height: measure.height, + geometry: measure.geometry, + scale: measure.scale, + isAnchored: true, + behindDoc: block.anchor?.behindDoc === true, + zIndex: getFragmentZIndex(block), + drawingContentId: block.drawingContentId, + sourceAnchor: block.sourceAnchor, + }; + if (contentMeasures) { + fragment.contentMeasures = contentMeasures; + } + const attrs = block.attrs as Record | undefined; + if (attrs?.pmStart != null) fragment.pmStart = attrs.pmStart as number; + if (attrs?.pmEnd != null) fragment.pmEnd = attrs.pmEnd as number; + state.page.fragments.push(fragment); + placedAnchoredIds.add(block.id); + } + } + } + + if (allowParagraphlessAnchoredTableFallback && shouldUseBlankPageFallback && paragraphlessAnchoredTables.length > 0) { const state = paginator.ensurePage(); for (const { block: tableBlock, measure: tableMeasure } of paragraphlessAnchoredTables) { @@ -2853,7 +2970,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } - if (allowSectionBreakOnlyPageFallback && pages.length === 0 && hasOnlySectionBreakBlocks(blocks)) { + if (allowSectionBreakOnlyPageFallback && shouldUseBlankPageFallback && hasOnlySectionBreakBlocks(blocks)) { paginator.ensurePage(); } diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index bd2465dbdc..00a0ce9ec7 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -3703,6 +3703,93 @@ describe('DomPainter', () => { expect(wrapperAfter?.style.fontSize).toBe('10px'); }); + it('keeps segment-positioned inline SDT text aligned with adjacent plain runs', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'segment-positioned-inline-sdt-baseline', + runs: [ + { text: 'KvK', fontFamily: 'Arial, sans-serif', fontSize: 16, pmStart: 0, pmEnd: 3 }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16 }, + { + text: 'KvK_number', + fontFamily: 'Arial, sans-serif', + fontSize: 16, + pmStart: 4, + pmEnd: 14, + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'header-value-sdt', + tag: 'inline_text_sdt', + alias: 'Header value', + lockMode: 'unlocked', + }, + }, + ], + attrs: {}, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 2, + toChar: 10, + width: 140, + maxWidth: 300, + ascent: 12, + descent: 4, + lineHeight: 20, + segments: [ + { runIndex: 0, fromChar: 0, toChar: 3, width: 28 }, + { runIndex: 2, fromChar: 0, toChar: 10, width: 92, x: 80 }, + ], + }, + ], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'segment-positioned-inline-sdt-baseline', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 0, + pmEnd: 14, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const wrapper = mount.querySelector( + '.superdoc-structured-content-inline[data-sdt-id="header-value-sdt"]', + ) as HTMLElement | null; + expect(wrapper).toBeTruthy(); + expect(wrapper?.style.padding).toBe('0px'); + expect(wrapper?.style.borderWidth).toBe('0px'); + expect(wrapper?.style.lineHeight).toBe('20px'); + + const value = wrapper?.querySelector('[data-pm-start="4"]') as HTMLElement | null; + expect(value).toBeTruthy(); + expect(value?.style.left).toBe('0px'); + expect(value?.style.top).toBe('0px'); + }); + it('uses first run font-size for inline SDT wrapper when a field has mixed run sizes', () => { const mixedSizeBlock: FlowBlock = { kind: 'paragraph', @@ -6263,7 +6350,7 @@ describe('DomPainter', () => { expect(behindDocEl).toBeTruthy(); expect(behindDocEl.style.top).toBe('40px'); - expect(behindDocEl.style.left).toBe('42px'); + expect(behindDocEl.style.left).toBe('12px'); }); it('renders footer page-relative media using normalized band-local coordinates', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 0ad87bf7e7..5409c760c5 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2249,7 +2249,7 @@ export class DomPainter { } fragEl.style.top = `${pageY}px`; - fragEl.style.left = `${marginLeft + fragment.x}px`; + fragEl.style.left = `${isPageRelative ? fragment.x : marginLeft + fragment.x}px`; fragEl.style.zIndex = '0'; // Same level as page, but inserted first so renders behind fragEl.dataset.behindDocSection = kind; // Track for cleanup on re-render // Insert at beginning of page so it renders behind body content due to DOM order diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.ts b/packages/layout-engine/painters/dom/src/runs/render-line.ts index 339454977f..b9f7ceb943 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -697,12 +697,17 @@ const renderExplicitlyPositionedRuns = ({ geoSdtWrapper.style.left = `${elemLeftPx}px`; geoSdtWrapper.style.top = '0px'; geoSdtWrapper.style.height = `${line.lineHeight}px`; + geoSdtWrapper.style.padding = '0px'; + geoSdtWrapper.style.borderWidth = '0px'; + geoSdtWrapper.style.lineHeight = `${line.lineHeight}px`; } if (isImageRun(runForSdt)) { geoSdtWrapper.dataset.containsInlineImage = 'true'; } runContext.syncInlineSdtWrapperTypography(geoSdtWrapper, runForSdt); + geoSdtWrapper.style.lineHeight = `${line.lineHeight}px`; elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`; + elem.style.top = '0px'; geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx); runContext.expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd); geoSdtWrapper.appendChild(elem); diff --git a/packages/super-editor/src/editors/v1/components/SuperEditor.test.js b/packages/super-editor/src/editors/v1/components/SuperEditor.test.js index 243aac2ea2..b0a6e9f175 100644 --- a/packages/super-editor/src/editors/v1/components/SuperEditor.test.js +++ b/packages/super-editor/src/editors/v1/components/SuperEditor.test.js @@ -1414,6 +1414,160 @@ describe('SuperEditor.vue', () => { vi.useRealTimers(); }); + it('should not show image resize overlay for a behind-doc header image outside header mode', async () => { + vi.useFakeTimers(); + EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); + + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-header-image-hover-body', + fileSource: new Blob([], { type: DOCX_MIME }), + options: {}, + }, + }); + + await flushPromises(); + await flushPromises(); + + const instance = getEditorInstance(); + instance.isEditable = true; + instance.options.documentMode = 'editing'; + wrapper.vm.getDocumentMode = () => 'editing'; + wrapper.vm.isViewingMode = () => false; + wrapper.vm.activeHeaderFooterMode = 'body'; + + const imageEl = document.createElement('div'); + imageEl.classList.add('superdoc-image-fragment'); + imageEl.dataset.behindDocSection = 'header'; + imageEl.dataset.sdBlockId = 'header-image'; + imageEl.setAttribute('data-image-metadata', '{"width":100}'); + + const event = new MouseEvent('mousemove'); + Object.defineProperty(event, 'target', { value: imageEl }); + + wrapper.vm.updateImageResizeOverlay(event); + + expect(wrapper.vm.imageResizeState.visible).toBe(false); + expect(wrapper.vm.imageResizeState.imageElement).toBe(null); + expect(wrapper.vm.imageResizeState.blockId).toBe(null); + + wrapper.unmount(); + vi.useRealTimers(); + }); + + it('should show image resize overlay for a behind-doc header image in header mode', async () => { + vi.useFakeTimers(); + EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); + + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-header-image-hover-header', + fileSource: new Blob([], { type: DOCX_MIME }), + options: {}, + }, + }); + + await flushPromises(); + await flushPromises(); + + const instance = getEditorInstance(); + instance.isEditable = true; + instance.options.documentMode = 'editing'; + wrapper.vm.getDocumentMode = () => 'editing'; + wrapper.vm.isViewingMode = () => false; + wrapper.vm.activeHeaderFooterMode = 'header'; + + const imageEl = document.createElement('div'); + imageEl.classList.add('superdoc-image-fragment'); + imageEl.dataset.behindDocSection = 'header'; + imageEl.dataset.sdBlockId = 'header-image'; + imageEl.setAttribute('data-image-metadata', '{"width":100}'); + + const event = new MouseEvent('mousemove'); + Object.defineProperty(event, 'target', { value: imageEl }); + + wrapper.vm.updateImageResizeOverlay(event); + + expect(wrapper.vm.imageResizeState.visible).toBe(true); + expect(wrapper.vm.imageResizeState.imageElement).toBe(imageEl); + expect(wrapper.vm.imageResizeState.blockId).toBe('header-image'); + + wrapper.unmount(); + vi.useRealTimers(); + }); + + it('should not apply image selection outline for a behind-doc header image outside header mode', async () => { + vi.useFakeTimers(); + EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); + + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-header-image-selection-body', + fileSource: new Blob([], { type: DOCX_MIME }), + options: {}, + }, + }); + + await flushPromises(); + await flushPromises(); + + const instance = getEditorInstance(); + instance.isEditable = true; + instance.options.documentMode = 'editing'; + wrapper.vm.getDocumentMode = () => 'editing'; + wrapper.vm.isViewingMode = () => false; + wrapper.vm.activeHeaderFooterMode = 'body'; + + const imageEl = document.createElement('div'); + imageEl.dataset.behindDocSection = 'header'; + + wrapper.vm.setSelectedImage(imageEl, 'header-image', 42); + + expect(imageEl.classList.contains('superdoc-image-selected')).toBe(false); + expect(wrapper.vm.selectedImageState.element).toBe(null); + expect(wrapper.vm.selectedImageState.blockId).toBe(null); + expect(wrapper.vm.selectedImageState.pmStart).toBe(null); + + wrapper.unmount(); + vi.useRealTimers(); + }); + + it('should apply image selection outline for a behind-doc header image in header mode', async () => { + vi.useFakeTimers(); + EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); + + const wrapper = mount(SuperEditor, { + props: { + documentId: 'doc-header-image-selection-header', + fileSource: new Blob([], { type: DOCX_MIME }), + options: {}, + }, + }); + + await flushPromises(); + await flushPromises(); + + const instance = getEditorInstance(); + instance.isEditable = true; + instance.options.documentMode = 'editing'; + wrapper.vm.getDocumentMode = () => 'editing'; + wrapper.vm.isViewingMode = () => false; + wrapper.vm.activeHeaderFooterMode = 'header'; + + const imageEl = document.createElement('div'); + imageEl.dataset.behindDocSection = 'header'; + + wrapper.vm.setSelectedImage(imageEl, 'header-image', 42); + + expect(imageEl.classList.contains('superdoc-image-selected')).toBe(true); + expect(wrapper.vm.selectedImageState.element).toBe(imageEl); + expect(wrapper.vm.selectedImageState.blockId).toBe('header-image'); + expect(wrapper.vm.selectedImageState.pmStart).toBe(42); + + wrapper.unmount(); + vi.useRealTimers(); + }); + it('should clear image selection when props switch to viewing mode', async () => { vi.useFakeTimers(); EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]); diff --git a/packages/super-editor/src/editors/v1/components/SuperEditor.vue b/packages/super-editor/src/editors/v1/components/SuperEditor.vue index 84c3827a6a..349aba7332 100644 --- a/packages/super-editor/src/editors/v1/components/SuperEditor.vue +++ b/packages/super-editor/src/editors/v1/components/SuperEditor.vue @@ -101,6 +101,8 @@ const currentZoom = ref(1); */ let zoomChangeHandler = null; let documentModeChangeHandler = null; +let headerFooterModeChangeHandler = null; +const activeHeaderFooterMode = ref('body'); // Watch for changes in options.rulers with deep option to catch nested changes watch( @@ -655,6 +657,16 @@ const onTableResizeEnd = () => { tableResizeState.tableElement = null; }; +const getBehindDocSection = (element: Element | null): string | null => { + return element?.closest?.('[data-behind-doc-section]')?.getAttribute('data-behind-doc-section') ?? null; +}; + +const canInteractWithHeaderFooterOwnedMedia = (element: Element | null): boolean => { + const section = getBehindDocSection(element); + if (!section) return true; + return section === activeHeaderFooterMode.value; +}; + /** * Update image resize overlay visibility based on mouse position. * Shows overlay when hovering over images with data-image-metadata attribute. @@ -699,6 +711,10 @@ const updateImageResizeOverlay = (event: MouseEvent): void => { // Check for standalone image fragments (ImageBlock) if (target.classList?.contains(DOM_CLASS_NAMES.IMAGE_FRAGMENT) && target.hasAttribute('data-image-metadata')) { + if (!canInteractWithHeaderFooterOwnedMedia(target)) { + hideImageResizeOverlay(); + return; + } imageResizeState.visible = true; imageResizeState.imageElement = target as HTMLElement; imageResizeState.blockId = target.getAttribute('data-sd-block-id'); @@ -710,6 +726,10 @@ const updateImageResizeOverlay = (event: MouseEvent): void => { target.classList?.contains(DOM_CLASS_NAMES.INLINE_IMAGE_CLIP_WRAPPER) && target.querySelector?.('[data-image-metadata]') ) { + if (!canInteractWithHeaderFooterOwnedMedia(target)) { + hideImageResizeOverlay(); + return; + } imageResizeState.visible = true; imageResizeState.imageElement = target as HTMLElement; imageResizeState.blockId = target.getAttribute('data-pm-start'); @@ -718,6 +738,10 @@ const updateImageResizeOverlay = (event: MouseEvent): void => { // Check for inline images (ImageRun inside paragraphs). When image has clipPath it is wrapped; // use the wrapper so the resizer works on the cropped portion's box. if (target.classList?.contains(DOM_CLASS_NAMES.INLINE_IMAGE) && target.hasAttribute('data-image-metadata')) { + if (!canInteractWithHeaderFooterOwnedMedia(target)) { + hideImageResizeOverlay(); + return; + } imageResizeState.visible = true; const wrapper = target.closest?.(`.${DOM_CLASS_NAMES.INLINE_IMAGE_CLIP_WRAPPER}`) as HTMLElement | null; imageResizeState.imageElement = (wrapper ?? target) as HTMLElement; @@ -770,6 +794,12 @@ const setSelectedImage = (element, blockId, pmStart) => { return; } + if (element && !canInteractWithHeaderFooterOwnedMedia(element)) { + clearSelectedImage(); + hideImageResizeOverlay(); + return; + } + // Remove selection from the previously selected element if (selectedImageState.element && selectedImageState.element !== element) { selectedImageState.element.classList.remove('superdoc-image-selected'); @@ -785,6 +815,15 @@ const setSelectedImage = (element, blockId, pmStart) => { } }; +const cleanupInactiveHeaderFooterOwnedImageUi = () => { + if (imageResizeState.imageElement && !canInteractWithHeaderFooterOwnedMedia(imageResizeState.imageElement)) { + hideImageResizeOverlay(); + } + if (selectedImageState.element && !canInteractWithHeaderFooterOwnedMedia(selectedImageState.element)) { + clearSelectedImage(); + } +}; + /** * Combined handler to update both table and image resize overlays */ @@ -1034,6 +1073,11 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } = presentationEditor.on('imageDeselected', () => { clearSelectedImage(); }); + headerFooterModeChangeHandler = ({ mode } = {}) => { + activeHeaderFooterMode.value = mode ?? 'body'; + cleanupInactiveHeaderFooterOwnedImageUi(); + }; + presentationEditor.on('headerFooterModeChanged', headerFooterModeChangeHandler); layoutUpdatedHandler = () => { if (imageResizeState.visible && imageResizeState.blockId) { @@ -1047,7 +1091,11 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } = newElement = editorElem.value?.querySelector(buildInlineImagePmSelector(escapedBlockId)); } if (newElement) { - imageResizeState.imageElement = newElement as HTMLElement; + if (canInteractWithHeaderFooterOwnedMedia(newElement)) { + imageResizeState.imageElement = newElement as HTMLElement; + } else { + hideImageResizeOverlay(); + } } else { imageResizeState.visible = false; imageResizeState.imageElement = null; @@ -1275,6 +1323,10 @@ onBeforeUnmount(() => { editor.value.off('zoomChange', zoomChangeHandler); zoomChangeHandler = null; } + if (editor.value instanceof PresentationEditor && headerFooterModeChangeHandler) { + editor.value.off('headerFooterModeChanged', headerFooterModeChangeHandler); + headerFooterModeChangeHandler = null; + } if (editor.value instanceof PresentationEditor && layoutUpdatedHandler) { editor.value.off('layoutUpdated', layoutUpdatedHandler); layoutUpdatedHandler = null; 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 2e72ab364f..d4a50caeda 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 @@ -5865,6 +5865,7 @@ export class PresentationEditor extends EventEmitter { pageIndex: session.pageIndex, pageNumber: session.pageNumber, }); + this.#refreshEditorDomAugmentations(); this.#updateAwarenessSession(); }, onEditingContext: (data) => { @@ -7745,6 +7746,7 @@ export class PresentationEditor extends EventEmitter { #refreshEditorDomAugmentations(): void { this.#postPaintPipeline.refreshAfterPaint({ layoutEpoch: this.#layoutEpoch, + activeHeaderFooterMode: this.#headerFooterSession?.session?.mode ?? 'body', editorState: this.#editor?.view?.state, domPositionIndex: this.#domPositionIndex, proofingAnnotations: this.#buildProofingAnnotations(), diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts index ba5b0d5563..1a6b07147f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts @@ -77,6 +77,70 @@ describe('ImageInteractionLayer', () => { expect(block.dataset.displayLabel).toBe('Block image'); }); + it('does not mark behind-doc header images as draggable outside header mode', () => { + container.innerHTML = ` +
+ Header image +
+ `; + + layer.apply(3, { activeHeaderFooterMode: 'body' }); + + const block = container.querySelector(`.${DOM_CLASS_NAMES.IMAGE_FRAGMENT}`) as HTMLElement; + expect(block.hasAttribute('draggable')).toBe(false); + expect(block.dataset.dragSourceKind).toBeUndefined(); + }); + + it('marks behind-doc header images as draggable while header mode is active', () => { + container.innerHTML = ` +
+ Header image +
+ `; + + layer.apply(3, { activeHeaderFooterMode: 'header' }); + + const block = container.querySelector(`.${DOM_CLASS_NAMES.IMAGE_FRAGMENT}`) as HTMLElement; + expect(block.draggable).toBe(true); + expect(block.dataset.dragSourceKind).toBe('existingImage'); + }); + + it('clears behind-doc image drag affordances when leaving the owning mode', () => { + container.innerHTML = ` +
+ Header image +
+ `; + + layer.apply(3, { activeHeaderFooterMode: 'header' }); + const block = container.querySelector(`.${DOM_CLASS_NAMES.IMAGE_FRAGMENT}`) as HTMLElement; + expect(block.draggable).toBe(true); + + layer.apply(3, { activeHeaderFooterMode: 'body' }); + + expect(block.hasAttribute('draggable')).toBe(false); + expect(block.dataset.dragSourceKind).toBeUndefined(); + expect(block.dataset.imageInteractionEpoch).toBeUndefined(); + }); + it('skips elements without PM position metadata', () => { container.innerHTML = `
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.ts index f6751a95a3..41e1ebfed6 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.ts @@ -2,6 +2,10 @@ import { DATASET_KEYS, DOM_CLASS_NAMES } from '@superdoc/dom-contract'; const INTERACTION_EPOCH_KEY = 'imageInteractionEpoch'; +type ImageInteractionApplyOptions = { + activeHeaderFooterMode?: 'body' | 'header' | 'footer' | string | null; +}; + function parsePmNumber(value: string | undefined): string | null { return value && value.trim().length > 0 ? value : null; } @@ -61,6 +65,25 @@ function resolveImageKind(root: HTMLElement): 'inline' | 'block' { return root.classList.contains(DOM_CLASS_NAMES.IMAGE_FRAGMENT) ? 'block' : 'inline'; } +function getBehindDocSection(root: HTMLElement): string | null { + return root.closest('[data-behind-doc-section]')?.dataset.behindDocSection ?? null; +} + +function canInteractWithRoot(root: HTMLElement, activeHeaderFooterMode: string | null | undefined): boolean { + const section = getBehindDocSection(root); + if (!section) return true; + return section === activeHeaderFooterMode; +} + +function clearImageRoot(root: HTMLElement): void { + root.removeAttribute('draggable'); + delete root.dataset.dragSourceKind; + delete root.dataset.imageKind; + delete root.dataset.nodeType; + delete root.dataset.displayLabel; + delete root.dataset[INTERACTION_EPOCH_KEY]; +} + export class ImageInteractionLayer { #container: HTMLElement | null = null; @@ -68,11 +91,16 @@ export class ImageInteractionLayer { this.#container = container; } - apply(layoutEpoch: number): void { + apply(layoutEpoch: number, options: ImageInteractionApplyOptions = {}): void { if (!this.#container) return; const epochStr = String(layoutEpoch); for (const root of collectImageRoots(this.#container)) { + if (!canInteractWithRoot(root, options.activeHeaderFooterMode ?? 'body')) { + clearImageRoot(root); + continue; + } + if (root.dataset[INTERACTION_EPOCH_KEY] === epochStr) continue; const pmStart = parsePmNumber(root.dataset.pmStart); @@ -94,12 +122,7 @@ export class ImageInteractionLayer { if (!this.#container) return; for (const root of collectImageRoots(this.#container)) { - root.removeAttribute('draggable'); - delete root.dataset.dragSourceKind; - delete root.dataset.imageKind; - delete root.dataset.nodeType; - delete root.dataset.displayLabel; - delete root.dataset[INTERACTION_EPOCH_KEY]; + clearImageRoot(root); } } } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.ts index 4ff0f5b436..7532f36d4f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/PresentationPostPaintPipeline.ts @@ -41,6 +41,7 @@ type PresentationPostPaintPipelineDeps = { type RefreshAfterPaintOptions = { layoutEpoch: number; + activeHeaderFooterMode?: string | null; editorState: EditorState | null | undefined; domPositionIndex: DomPositionIndex; proofingAnnotations: ProofingAnnotation[] | null | undefined; @@ -137,7 +138,7 @@ export class PresentationPostPaintPipeline { refreshAfterPaint(options: RefreshAfterPaintOptions): void { this.#fieldAnnotationLayer.apply(options.layoutEpoch); options.rebuildDomPositionIndex(); - this.#imageLayer.apply(options.layoutEpoch); + this.#imageLayer.apply(options.layoutEpoch, { activeHeaderFooterMode: options.activeHeaderFooterMode }); this.#structuredContentLayer.apply(options.layoutEpoch); this.syncInlineStyleLayers(options.editorState, options.domPositionIndex); this.applyProofingAnnotations(options.proofingAnnotations, options.rebuildDomPositionIndex); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 3b18001d25..8ac2cfcf86 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -235,6 +235,8 @@ export function handleImageNode(node, params, isAnchor) { ) : null; const wrap = isAnchor ? { type: wrapNode?.name.slice(7) || 'None', attrs: {} } : { type: 'Inline' }; + const hasBehindDocAttribute = isAnchor && attributes.behindDoc != null; + const isBehindDoc = attributes.behindDoc === '1' || attributes.behindDoc === 1 || attributes.behindDoc === true; switch (wrap.type) { case 'Square': @@ -289,7 +291,7 @@ export function handleImageNode(node, params, isAnchor) { } break; case 'None': - wrap.attrs.behindDoc = node.attributes?.behindDoc === '1'; + wrap.attrs.behindDoc = isBehindDoc; break; case 'Inline': break; @@ -302,6 +304,9 @@ export function handleImageNode(node, params, isAnchor) { if (wrap.type === 'Square' || wrap.type === 'Tight' || wrap.type === 'Through' || wrap.type === 'TopAndBottom') { mergeAnchorPaddingIntoWrapDistances(wrap, padding); } + if (hasBehindDocAttribute && wrap.attrs) { + wrap.attrs.behindDoc = isBehindDoc; + } const docPr = node.elements.find((el) => el.name === 'wp:docPr'); const isHidden = isDocPrHidden(docPr); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 83eb9c5655..80196b15ae 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -656,6 +656,19 @@ describe('handleImageNode', () => { expect(result.attrs.wrap.attrs.wrapText).toBe('bothSides'); }); + it('preserves anchor behindDoc for non-None wrap types', () => { + const node = makeNode({ attributes: { behindDoc: '1' } }); + node.elements.push({ + name: 'wp:wrapSquare', + attributes: { wrapText: 'bothSides' }, + }); + + const result = handleImageNode(node, makeParams(), true); + + expect(result.attrs.wrap.type).toBe('Square'); + expect(result.attrs.wrap.attrs.behindDoc).toBe(true); + }); + it('handles wrap type Square with distance attributes', () => { const node = makeNode(); node.elements.push({ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js index 5f87ad3709..75417a26ef 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js @@ -13,6 +13,7 @@ import { SuperConverter } from '@converter/SuperConverter.js'; * Matches: header.xml, header1.xml, footer.xml, footer2.xml, etc. */ const HEADER_FOOTER_FILENAME_PATTERN = /^(header|footer)\d*\.xml$/i; +const DEFAULT_TAB_INTERVAL_TWIPS = 720; /** * Recursively collects all paragraph nodes (w:p) from a text box content structure. @@ -41,6 +42,10 @@ export function collectTextBoxParagraphs(nodes, paragraphs = []) { paragraphs.push(node); return; } + if (node.name === 'w:tbl') { + paragraphs.push(...flattenTextBoxTableToParagraphs(node)); + return; + } if (Array.isArray(node.elements)) { collectTextBoxParagraphs(node.elements, paragraphs); } @@ -48,6 +53,268 @@ export function collectTextBoxParagraphs(nodes, paragraphs = []) { return paragraphs; } +function flattenTextBoxTableToParagraphs(table) { + const rows = table.elements?.filter((node) => node?.name === 'w:tr') || []; + if (!rows.length) return []; + + const gridWidths = extractTableGridWidths(table); + const paragraphs = []; + + rows.forEach((row) => { + const cells = row.elements?.filter((node) => node?.name === 'w:tc') || []; + if (!cells.length) return; + + const columnStarts = buildColumnStarts(cells, gridWidths); + const cellLines = cells.map((cell) => collectTextBoxTableCellLines(cell)); + const maxLineCount = cellLines.reduce((max, lines) => Math.max(max, lines.length), 0); + + for (let lineIndex = 0; lineIndex < maxLineCount; lineIndex += 1) { + const lineParts = cellLines.map((lines) => lines[lineIndex] || null); + if (!lineParts.some((line) => line && hasRenderableLineContent(line.elements))) continue; + paragraphs.push(buildTableVisualLineParagraph(lineParts, columnStarts)); + } + }); + + return paragraphs; +} + +function extractTableGridWidths(table) { + const grid = table.elements?.find((node) => node?.name === 'w:tblGrid'); + return ( + grid?.elements + ?.filter((node) => node?.name === 'w:gridCol') + .map((node) => toFiniteNumber(node.attributes?.['w:w'] ?? node.attributes?.w)) + .filter((value) => value != null && value > 0) || [] + ); +} + +function buildColumnStarts(cells, gridWidths) { + const starts = []; + let cursor = 0; + let gridIndex = 0; + + cells.forEach((cell) => { + starts.push(cursor); + const gridSpan = resolveCellGridSpan(cell); + cursor += resolveCellWidth(cell, gridWidths, gridIndex, gridSpan); + gridIndex += gridSpan; + }); + + return starts; +} + +function resolveCellGridSpan(cell) { + const tcPr = cell.elements?.find((node) => node?.name === 'w:tcPr'); + const gridSpan = tcPr?.elements?.find((node) => node?.name === 'w:gridSpan'); + const value = toFiniteNumber(gridSpan?.attributes?.['w:val'] ?? gridSpan?.attributes?.val); + return value && value > 0 ? Math.max(1, Math.floor(value)) : 1; +} + +function resolveCellWidth(cell, gridWidths, gridIndex, gridSpan) { + const gridWidth = sumGridWidths(gridWidths, gridIndex, gridSpan); + if (gridWidth > 0) return gridWidth; + + const tcPr = cell.elements?.find((node) => node?.name === 'w:tcPr'); + const tcW = tcPr?.elements?.find((node) => node?.name === 'w:tcW'); + const width = toFiniteNumber(tcW?.attributes?.['w:w'] ?? tcW?.attributes?.w); + return width && width > 0 ? width : 0; +} + +function sumGridWidths(gridWidths, gridIndex, gridSpan) { + if (!Array.isArray(gridWidths) || gridWidths.length === 0) return 0; + + let width = 0; + for (let offset = 0; offset < gridSpan; offset += 1) { + const gridWidth = gridWidths[gridIndex + offset]; + if (gridWidth != null && gridWidth > 0) { + width += gridWidth; + } + } + return width; +} + +function collectTextBoxTableCellLines(cell) { + const paragraphNodes = []; + collectTextBoxParagraphsSkippingTables(cell.elements || [], paragraphNodes); + return paragraphNodes.flatMap((paragraph) => splitTextBoxParagraphIntoVisualLines(paragraph)); +} + +function collectTextBoxParagraphsSkippingTables(nodes, paragraphs) { + if (!Array.isArray(nodes)) return; + nodes.forEach((node) => { + if (!node) return; + if (node.name === 'w:p') { + paragraphs.push(node); + return; + } + if (node.name === 'w:tbl') return; + if (Array.isArray(node.elements)) { + collectTextBoxParagraphsSkippingTables(node.elements, paragraphs); + } + }); +} + +function splitTextBoxParagraphIntoVisualLines(paragraph) { + const pPr = paragraph.elements?.find((node) => node?.name === 'w:pPr') || null; + const lines = [{ pPr, elements: [] }]; + + const appendToCurrentLine = (element) => { + lines[lines.length - 1].elements.push(element); + }; + + for (const element of paragraph.elements || []) { + if (!element || element.name === 'w:pPr') continue; + if (element.name !== 'w:r' || !Array.isArray(element.elements)) { + appendToCurrentLine(carbonCopy(element)); + continue; + } + + splitRunAroundBreaks(element, appendToCurrentLine, () => { + lines.push({ pPr, elements: [] }); + }); + } + + while (lines.length > 1 && !hasRenderableLineContent(lines[lines.length - 1].elements)) { + lines.pop(); + } + + return lines; +} + +function splitRunAroundBreaks(run, appendRun, startNewLine) { + let runElements = []; + const runProperties = run.elements?.filter((node) => node?.name === 'w:rPr').map((node) => carbonCopy(node)) || []; + + const flushRun = () => { + const meaningfulElements = runElements.filter((node) => node?.name !== 'w:rPr'); + if (!meaningfulElements.length) { + runElements = runProperties.map((node) => carbonCopy(node)); + return; + } + + appendRun({ + ...carbonCopy(run), + elements: runElements, + }); + runElements = runProperties.map((node) => carbonCopy(node)); + }; + + run.elements.forEach((child) => { + if (child?.name === 'w:br') { + flushRun(); + startNewLine(); + return; + } + runElements.push(carbonCopy(child)); + }); + + flushRun(); +} + +function buildTableVisualLineParagraph(lineParts, columnStarts) { + const baseLine = lineParts.find((line) => line?.pPr) || lineParts.find(Boolean); + const pPr = buildVisualLineParagraphProperties(baseLine?.pPr, lineParts, columnStarts); + const elements = pPr ? [pPr] : []; + + lineParts.forEach((line, index) => { + if (!line || !hasRenderableLineContent(line.elements)) return; + if (index > 0) elements.push(createTabRun()); + elements.push(...line.elements.map((element) => carbonCopy(element))); + }); + + return { name: 'w:p', elements }; +} + +function buildVisualLineParagraphProperties(basePPr, lineParts, columnStarts) { + const pPr = basePPr ? carbonCopy(basePPr) : { name: 'w:pPr', elements: [] }; + pPr.elements = (pPr.elements || []).filter((node) => node?.name !== 'w:tabs'); + + const tabStops = []; + lineParts.forEach((line, index) => { + const columnStart = columnStarts[index] || 0; + if (index > 0 && columnStart > 0 && line && hasRenderableLineContent(line.elements)) { + tabStops.push(createTabStop(columnStart)); + } + + const sourceTabs = extractTabs(line?.pPr); + let positionedSourceTabCount = 0; + sourceTabs.forEach((tab) => { + const pos = toFiniteNumber(tab.attributes?.['w:pos'] ?? tab.attributes?.pos); + if (pos == null) return; + positionedSourceTabCount += 1; + tabStops.push(createTabStop(columnStart + pos, tab.attributes)); + }); + + const tabRunCount = countTabRuns(line?.elements); + for (let tabIndex = positionedSourceTabCount; tabIndex < tabRunCount; tabIndex += 1) { + tabStops.push(createTabStop(resolveDefaultInternalTabPos(columnStart, tabIndex))); + } + }); + + if (tabStops.length > 0) { + pPr.elements.push({ name: 'w:tabs', elements: dedupeTabStops(tabStops) }); + } + + return pPr.elements.length > 0 ? pPr : null; +} + +function extractTabs(pPr) { + const tabs = pPr?.elements?.find((node) => node?.name === 'w:tabs'); + return tabs?.elements?.filter((node) => node?.name === 'w:tab') || []; +} + +function countTabRuns(elements = []) { + return elements.reduce((count, element) => { + if (element?.name === 'w:tab') return count + 1; + if (Array.isArray(element?.elements)) return count + countTabRuns(element.elements); + return count; + }, 0); +} + +function resolveDefaultInternalTabPos(columnStart, tabIndex) { + return columnStart + DEFAULT_TAB_INTERVAL_TWIPS * (tabIndex + 1); +} + +function createTabRun() { + return { name: 'w:r', elements: [{ name: 'w:tab' }] }; +} + +function createTabStop(pos, sourceAttributes = {}) { + return { + name: 'w:tab', + attributes: { + ...sourceAttributes, + 'w:val': sourceAttributes['w:val'] || sourceAttributes.val || 'left', + 'w:pos': String(pos), + }, + }; +} + +function dedupeTabStops(tabStops) { + const seen = new Set(); + return tabStops + .filter((tab) => { + const key = `${tab.attributes?.['w:val'] || ''}:${tab.attributes?.['w:pos'] || ''}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => Number(a.attributes?.['w:pos'] || 0) - Number(b.attributes?.['w:pos'] || 0)); +} + +function hasRenderableLineContent(elements) { + return elements.some((element) => { + if (element?.name === 'w:tab') return true; + if (element?.name === 'w:t') return true; + return Array.isArray(element?.elements) && hasRenderableLineContent(element.elements); + }); +} + +function toFiniteNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + /** * Pre-processes text box content to handle field codes (PAGE, NUMPAGES, etc.). * Creates a deep copy to avoid mutating the original content. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.test.js index bee7acc5b3..1645200978 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.test.js @@ -54,6 +54,26 @@ vi.mock('@converter/SuperConverter.js', () => ({ }, })); +function extractLineTokens(paragraph) { + const tokens = []; + const visit = (nodes = []) => { + nodes.forEach((node) => { + if (node?.name === 'w:pPr' || node?.name === 'w:tabs') return; + if (node?.name === 'w:t') { + tokens.push(node.elements?.[0]?.text ?? ''); + return; + } + if (node?.name === 'w:tab') { + tokens.push('\t'); + return; + } + visit(node?.elements); + }); + }; + visit(paragraph.elements); + return tokens; +} + describe('textbox-content-helpers', () => { beforeEach(() => { vi.clearAllMocks(); @@ -155,6 +175,212 @@ describe('textbox-content-helpers', () => { const result = collectTextBoxParagraphs(nodes); expect(result).toHaveLength(0); }); + + it('should flatten textbox tables into visual rows with shifted tab stops', () => { + const p = (elements, pPrElements = []) => ({ + name: 'w:p', + elements: pPrElements.length ? [{ name: 'w:pPr', elements: pPrElements }, ...elements] : elements, + }); + const runText = (text) => ({ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text }] }] }); + const runTab = () => ({ name: 'w:r', elements: [{ name: 'w:tab' }] }); + const runBreak = () => ({ name: 'w:r', elements: [{ name: 'w:br' }] }); + const spacing = { name: 'w:spacing', attributes: { 'w:line': '240' } }; + const rightTabs = { + name: 'w:tabs', + elements: [{ name: 'w:tab', attributes: { 'w:val': 'left', 'w:pos': '1420' } }], + }; + const table = { + name: 'w:tbl', + elements: [ + { + name: 'w:tblGrid', + elements: [ + { name: 'w:gridCol', attributes: { 'w:w': '4253' } }, + { name: 'w:gridCol', attributes: { 'w:w': '3124' } }, + ], + }, + { + name: 'w:tr', + elements: [ + { + name: 'w:tc', + elements: [ + p([runText('Test Name'), runBreak(), runText('Stadsplateau 23B')], [spacing]), + p([runText('3521 AZ Utrecht')], [spacing]), + p([runText('Nederland')], [spacing]), + ], + }, + { + name: 'w:tc', + elements: [ + p( + [ + runText('KvK'), + runTab(), + runText('KvK_number'), + runBreak(), + runText('IBAN'), + runTab(), + runText('DE123456789'), + runBreak(), + runText('BIC'), + runTab(), + runText('ABCDEFG'), + runBreak(), + ], + [spacing, rightTabs], + ), + ], + }, + ], + }, + ], + }; + + const result = collectTextBoxParagraphs([table]); + + expect(result).toHaveLength(4); + expect(extractLineTokens(result[0])).toEqual(['Test Name', '\t', 'KvK', '\t', 'KvK_number']); + expect(extractLineTokens(result[1])).toEqual(['Stadsplateau 23B', '\t', 'IBAN', '\t', 'DE123456789']); + expect(extractLineTokens(result[2])).toEqual(['3521 AZ Utrecht', '\t', 'BIC', '\t', 'ABCDEFG']); + expect(extractLineTokens(result[3])).toEqual(['Nederland']); + + const firstLineTabs = result[0].elements + .find((node) => node.name === 'w:pPr') + .elements.find((node) => node.name === 'w:tabs').elements; + expect(firstLineTabs.map((tab) => tab.attributes['w:pos'])).toEqual(['4253', '5673']); + }); + + it('should synthesize internal tab stops for textbox table cells without explicit tabs', () => { + const p = (elements, pPrElements = []) => ({ + name: 'w:p', + elements: pPrElements.length ? [{ name: 'w:pPr', elements: pPrElements }, ...elements] : elements, + }); + const runText = (text) => ({ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text }] }] }); + const runTab = () => ({ name: 'w:r', elements: [{ name: 'w:tab' }] }); + const runBreak = () => ({ name: 'w:r', elements: [{ name: 'w:br' }] }); + const spacing = { name: 'w:spacing', attributes: { 'w:line': '240' } }; + const table = { + name: 'w:tbl', + elements: [ + { + name: 'w:tblGrid', + elements: [ + { name: 'w:gridCol', attributes: { 'w:w': '4253' } }, + { name: 'w:gridCol', attributes: { 'w:w': '3124' } }, + ], + }, + { + name: 'w:tr', + elements: [ + { + name: 'w:tc', + elements: [ + p([runText('Test '), runText('Name'), runBreak(), runText('Stadsplateau 23B')], [spacing]), + p([runText('3521 AZ Utrecht')], [spacing]), + p([runText('Nederland')], [spacing]), + ], + }, + { + name: 'w:tc', + elements: [ + p( + [ + runText('KvK'), + runTab(), + runText('KvK_number'), + runBreak(), + runText('IBAN'), + runTab(), + runText('DE123456789'), + runBreak(), + runText('BIC'), + runTab(), + runText('ABCDEFG'), + ], + [spacing], + ), + ], + }, + ], + }, + ], + }; + + const result = collectTextBoxParagraphs([table]); + + expect(result).toHaveLength(4); + expect(extractLineTokens(result[0])).toEqual(['Test ', 'Name', '\t', 'KvK', '\t', 'KvK_number']); + expect(extractLineTokens(result[1])).toEqual(['Stadsplateau 23B', '\t', 'IBAN', '\t', 'DE123456789']); + expect(extractLineTokens(result[2])).toEqual(['3521 AZ Utrecht', '\t', 'BIC', '\t', 'ABCDEFG']); + expect(extractLineTokens(result[3])).toEqual(['Nederland']); + + const tabPositions = result.slice(0, 3).map((paragraph) => + paragraph.elements + .find((node) => node.name === 'w:pPr') + .elements.find((node) => node.name === 'w:tabs') + .elements.map((tab) => tab.attributes['w:pos']), + ); + expect(tabPositions).toEqual([ + ['4253', '4973'], + ['4253', '4973'], + ['4253', '4973'], + ]); + }); + + it('should advance textbox table column starts by merged cell grid spans', () => { + const p = (elements) => ({ name: 'w:p', elements }); + const runText = (text) => ({ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text }] }] }); + const runTab = () => ({ name: 'w:r', elements: [{ name: 'w:tab' }] }); + const table = { + name: 'w:tbl', + elements: [ + { + name: 'w:tblGrid', + elements: [ + { name: 'w:gridCol', attributes: { 'w:w': '1000' } }, + { name: 'w:gridCol', attributes: { 'w:w': '2000' } }, + { name: 'w:gridCol', attributes: { 'w:w': '3000' } }, + { name: 'w:gridCol', attributes: { 'w:w': '4000' } }, + ], + }, + { + name: 'w:tr', + elements: [ + { + name: 'w:tc', + elements: [ + { + name: 'w:tcPr', + elements: [{ name: 'w:gridSpan', attributes: { 'w:val': '2' } }], + }, + p([runText('Merged')]), + ], + }, + { + name: 'w:tc', + elements: [p([runText('Next'), runTab(), runText('Value')])], + }, + { + name: 'w:tc', + elements: [p([runText('Last')])], + }, + ], + }, + ], + }; + + const result = collectTextBoxParagraphs([table]); + + expect(result).toHaveLength(1); + expect(extractLineTokens(result[0])).toEqual(['Merged', '\t', 'Next', '\t', 'Value', '\t', 'Last']); + + const tabPositions = result[0].elements + .find((node) => node.name === 'w:pPr') + .elements.find((node) => node.name === 'w:tabs') + .elements.map((tab) => tab.attributes['w:pos']); + expect(tabPositions).toEqual(['3000', '3720', '6000']); + }); }); describe('preProcessTextBoxContent', () => {