diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 91fcb2410..638cf019d 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1402,6 +1402,12 @@ export type Page = { number: number; fragments: Fragment[]; margins?: PageMargins; + /** + * Extra bottom space reserved on this page for footnotes (in px). + * Used by consumers (e.g. editors/painters) to keep footer hit regions and + * decoration boxes anchored to the real bottom margin while the body shrinks. + */ + footnoteReserved?: number; numberText?: string; size?: { w: number; h: number }; orientation?: 'portrait' | 'landscape'; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 5e536ccf3..b0018c922 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -42,6 +42,12 @@ export type IncrementalLayoutResult = { dirty: ReturnType; headers?: HeaderFooterLayoutResult[]; footers?: HeaderFooterLayoutResult[]; + /** + * Extra blocks/measures that should be added to the painter's lookup table. + * Used for rendering non-body fragments injected into the layout (e.g., footnotes). + */ + extraBlocks?: FlowBlock[]; + extraMeasures?: Measure[]; }; export const measureCache = new MeasureCache(); @@ -57,6 +63,115 @@ const perfLog = (...args: unknown[]): void => { console.log(...args); }; +type FootnoteReference = { id: string; pos: number }; +type FootnotesLayoutInput = { + refs: FootnoteReference[]; + blocksById: Map; + gap?: number; + topPadding?: number; + dividerHeight?: number; +}; + +const isFootnotesLayoutInput = (value: unknown): value is FootnotesLayoutInput => { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + if (!Array.isArray(v.refs)) return false; + if (!(v.blocksById instanceof Map)) return false; + return true; +}; + +const getMeasureHeight = (measure: Measure | undefined): number => { + if (!measure) return 0; + if (measure.kind === 'paragraph') return Math.max(0, measure.totalHeight ?? 0); + if (measure.kind === 'image') return Math.max(0, measure.height ?? 0); + if (measure.kind === 'drawing') return Math.max(0, measure.height ?? 0); + if (measure.kind === 'table') return Math.max(0, measure.totalHeight ?? 0); + if (measure.kind === 'list') return Math.max(0, measure.totalHeight ?? 0); + return 0; +}; + +const findPageIndexForPos = (layout: Layout, pos: number): number | null => { + if (!Number.isFinite(pos)) return null; + const fallbackRanges: Array<{ pageIndex: number; minStart: number; maxEnd: number } | null> = []; + for (let pageIndex = 0; pageIndex < layout.pages.length; pageIndex++) { + const page = layout.pages[pageIndex]; + let minStart: number | null = null; + let maxEnd: number | null = null; + for (const fragment of page.fragments) { + const pmStart = (fragment as { pmStart?: number }).pmStart; + const pmEnd = (fragment as { pmEnd?: number }).pmEnd; + if (pmStart == null || pmEnd == null) continue; + if (minStart == null || pmStart < minStart) minStart = pmStart; + if (maxEnd == null || pmEnd > maxEnd) maxEnd = pmEnd; + if (pos >= pmStart && pos <= pmEnd) { + return pageIndex; + } + } + fallbackRanges[pageIndex] = + minStart != null && maxEnd != null ? { pageIndex, minStart, maxEnd } : null; + } + + // Fallback: pick the closest page range when exact containment isn't found. + // This helps when pm ranges are sparse or use slightly different boundary semantics. + let best: { pageIndex: number; distance: number } | null = null; + for (const entry of fallbackRanges) { + if (!entry) continue; + const distance = + pos < entry.minStart ? entry.minStart - pos : pos > entry.maxEnd ? pos - entry.maxEnd : 0; + if (!best || distance < best.distance) { + best = { pageIndex: entry.pageIndex, distance }; + } + } + if (best) return best.pageIndex; + if (layout.pages.length > 0) return layout.pages.length - 1; + return null; +}; + +const assignFootnotesToPages = (layout: Layout, refs: FootnoteReference[]): Map => { + const result = new Map(); + const seenByPage = new Map>(); + for (const ref of refs) { + const pageIndex = findPageIndexForPos(layout, ref.pos); + if (pageIndex == null) continue; + let seen = seenByPage.get(pageIndex); + if (!seen) { + seen = new Set(); + seenByPage.set(pageIndex, seen); + } + if (seen.has(ref.id)) continue; + seen.add(ref.id); + const list = result.get(pageIndex) ?? []; + list.push(ref.id); + result.set(pageIndex, list); + } + return result; +}; + +const resolveFootnoteMeasurementWidth = (options: LayoutOptions, blocks?: FlowBlock[]): number => { + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const margins = { + right: normalizeMargin(options.margins?.right, DEFAULT_MARGINS.right), + left: normalizeMargin(options.margins?.left, DEFAULT_MARGINS.left), + }; + let width = pageSize.w - (margins.left + margins.right); + + if (blocks && blocks.length > 0) { + for (const block of blocks) { + if (block.kind !== 'sectionBreak') continue; + const sectionPageSize = block.pageSize ?? pageSize; + const sectionMargins = { + right: normalizeMargin(block.margins?.right, margins.right), + left: normalizeMargin(block.margins?.left, margins.left), + }; + const w = sectionPageSize.w - (sectionMargins.left + sectionMargins.right); + if (w > 0 && w < width) width = w; + } + } + + if (!Number.isFinite(width) || width <= 0) return 0; + return width; +}; + /** * Performs incremental layout of document blocks with header/footer support. * @@ -553,6 +668,241 @@ export async function incrementalLayout( }); } + // Footnotes: reserve space per page and inject footnote fragments into the layout. + // 1) Assign footnote refs to pages using the current layout. + // 2) Measure footnote blocks and compute per-page reserved height. + // 3) Relayout with per-page bottom margin reserves, then inject fragments into the reserved band. + let extraBlocks: FlowBlock[] | undefined; + let extraMeasures: Measure[] | undefined; + const footnotesInput = isFootnotesLayoutInput(options.footnotes) ? options.footnotes : null; + if (footnotesInput && footnotesInput.refs.length > 0 && footnotesInput.blocksById.size > 0) { + const gap = typeof footnotesInput.gap === 'number' && Number.isFinite(footnotesInput.gap) ? footnotesInput.gap : 2; + const topPadding = + typeof footnotesInput.topPadding === 'number' && Number.isFinite(footnotesInput.topPadding) + ? footnotesInput.topPadding + : 6; + const dividerHeight = + typeof footnotesInput.dividerHeight === 'number' && Number.isFinite(footnotesInput.dividerHeight) + ? footnotesInput.dividerHeight + : 6; + + const footnoteWidth = resolveFootnoteMeasurementWidth(options, currentBlocks); + if (footnoteWidth > 0) { + const footnoteConstraints = { maxWidth: footnoteWidth, maxHeight: measurementHeight }; + + const measureFootnoteBlocks = async (idsByPage: Map) => { + const needed = new Map(); + idsByPage.forEach((ids) => { + ids.forEach((id) => { + const blocks = footnotesInput.blocksById.get(id) ?? []; + blocks.forEach((block) => { + if (block?.id && !needed.has(block.id)) { + needed.set(block.id, block); + } + }); + }); + }); + + const blocks = Array.from(needed.values()); + const measuresById = new Map(); + await Promise.all( + blocks.map(async (block) => { + const cached = measureCache.get(block, footnoteConstraints.maxWidth, footnoteConstraints.maxHeight); + if (cached) { + measuresById.set(block.id, cached); + return; + } + const measurement = await measureBlock(block, footnoteConstraints); + measureCache.set(block, footnoteConstraints.maxWidth, footnoteConstraints.maxHeight, measurement); + measuresById.set(block.id, measurement); + }), + ); + return { blocks, measuresById }; + }; + + const computeReserves = ( + layoutForPages: Layout, + idsByPage: Map, + measuresById: Map, + ) => { + const reserves: number[] = []; + for (let pageIndex = 0; pageIndex < layoutForPages.pages.length; pageIndex++) { + const ids = idsByPage.get(pageIndex) ?? []; + if (ids.length === 0) { + reserves[pageIndex] = 0; + continue; + } + let height = dividerHeight + topPadding; + ids.forEach((id, idIndex) => { + const blocks = footnotesInput.blocksById.get(id) ?? []; + blocks.forEach((block) => { + height += getMeasureHeight(measuresById.get(block.id)); + }); + if (idIndex < ids.length - 1) { + height += gap; + } + }); + reserves[pageIndex] = Math.max(0, Math.ceil(height)); + } + return reserves; + }; + + const injectFragments = ( + layoutForPages: Layout, + idsByPage: Map, + measuresById: Map, + reservesByPageIndex: number[], + ) => { + const decorativeBlocks: FlowBlock[] = []; + const decorativeMeasures: Measure[] = []; + + for (let pageIndex = 0; pageIndex < layoutForPages.pages.length; pageIndex++) { + const page = layoutForPages.pages[pageIndex]; + page.footnoteReserved = Math.max(0, reservesByPageIndex[pageIndex] ?? 0); + const ids = idsByPage.get(pageIndex) ?? []; + if (ids.length === 0) continue; + if (!page.margins) continue; + + const pageSize = page.size ?? layoutForPages.pageSize; + const pageContentWidth = pageSize.w - ((page.margins.left ?? 0) + (page.margins.right ?? 0)); + const contentWidth = Math.min(pageContentWidth, footnoteWidth); + if (!Number.isFinite(contentWidth) || contentWidth <= 0) continue; + const bandTopY = pageSize.h - (page.margins.bottom ?? 0); + const x = page.margins.left ?? 0; + + // Optional visible separator line (Word-like). Uses a 1px filled rect. + let cursorY = bandTopY; + if (dividerHeight > 0 && contentWidth > 0) { + const separatorId = `footnote-separator-page-${page.number}`; + decorativeBlocks.push({ + kind: 'drawing', + id: separatorId, + drawingKind: 'vectorShape', + geometry: { width: contentWidth, height: dividerHeight }, + shapeKind: 'rect', + fillColor: '#000000', + strokeColor: null, + strokeWidth: 0, + }); + decorativeMeasures.push({ + kind: 'drawing', + drawingKind: 'vectorShape', + width: contentWidth, + height: dividerHeight, + scale: 1, + naturalWidth: contentWidth, + naturalHeight: dividerHeight, + geometry: { width: contentWidth, height: dividerHeight }, + }); + page.fragments.push({ + kind: 'drawing', + blockId: separatorId, + drawingKind: 'vectorShape', + x, + y: cursorY, + width: contentWidth, + height: dividerHeight, + geometry: { width: contentWidth, height: dividerHeight }, + scale: 1, + }); + cursorY += dividerHeight; + } + cursorY += topPadding; + + ids.forEach((id, idIndex) => { + const blocks = footnotesInput.blocksById.get(id) ?? []; + blocks.forEach((block) => { + const measure = measuresById.get(block.id); + if (!measure || measure.kind !== 'paragraph') return; + const linesCount = measure.lines?.length ?? 0; + if (linesCount === 0) return; + page.fragments.push({ + kind: 'para', + blockId: block.id, + fromLine: 0, + toLine: linesCount, + x, + y: cursorY, + width: contentWidth, + }); + cursorY += getMeasureHeight(measure); + }); + if (idIndex < ids.length - 1) { + cursorY += gap; + } + }); + } + + return { decorativeBlocks, decorativeMeasures }; + }; + + // Pass 1: assign + reserve from current layout. + let idsByPage = assignFootnotesToPages(layout, footnotesInput.refs); + let { blocks: measuredFootnoteBlocks, measuresById } = await measureFootnoteBlocks(idsByPage); + let reserves = computeReserves(layout, idsByPage, measuresById); + + // If any reserves, relayout once, then re-assign and inject. + if (reserves.some((h) => h > 0)) { + layout = layoutDocument(currentBlocks, currentMeasures, { + ...options, + footnoteReservedByPageIndex: reserves, + headerContentHeights, + footerContentHeights, + remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => + remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), + }); + + // Pass 2: recompute assignment and reserves for the updated pagination. + idsByPage = assignFootnotesToPages(layout, footnotesInput.refs); + ({ blocks: measuredFootnoteBlocks, measuresById } = await measureFootnoteBlocks(idsByPage)); + reserves = computeReserves(layout, idsByPage, measuresById); + + // Apply final reserves (best-effort second relayout) then inject fragments. + layout = layoutDocument(currentBlocks, currentMeasures, { + ...options, + footnoteReservedByPageIndex: reserves, + headerContentHeights, + footerContentHeights, + remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => + remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), + }); + let finalIdsByPage = assignFootnotesToPages(layout, footnotesInput.refs); + let { blocks: finalBlocks, measuresById: finalMeasuresById } = await measureFootnoteBlocks(finalIdsByPage); + const finalReserves = computeReserves(layout, finalIdsByPage, finalMeasuresById); + let reservesAppliedToLayout = reserves; + const reservesDiffer = + finalReserves.length !== reserves.length || + finalReserves.some((h, i) => (reserves[i] ?? 0) !== h) || + reserves.some((h, i) => (finalReserves[i] ?? 0) !== h); + if (reservesDiffer) { + layout = layoutDocument(currentBlocks, currentMeasures, { + ...options, + footnoteReservedByPageIndex: finalReserves, + headerContentHeights, + footerContentHeights, + remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => + remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), + }); + reservesAppliedToLayout = finalReserves; + finalIdsByPage = assignFootnotesToPages(layout, footnotesInput.refs); + ({ blocks: finalBlocks, measuresById: finalMeasuresById } = await measureFootnoteBlocks(finalIdsByPage)); + } + const injected = injectFragments(layout, finalIdsByPage, finalMeasuresById, reservesAppliedToLayout); + + const alignedBlocks: FlowBlock[] = []; + const alignedMeasures: Measure[] = []; + finalBlocks.forEach((block) => { + const measure = finalMeasuresById.get(block.id); + if (!measure) return; + alignedBlocks.push(block); + alignedMeasures.push(measure); + }); + extraBlocks = injected ? alignedBlocks.concat(injected.decorativeBlocks) : alignedBlocks; + extraMeasures = injected ? alignedMeasures.concat(injected.decorativeMeasures) : alignedMeasures; + } + } + } + let headers: HeaderFooterLayoutResult[] | undefined; let footers: HeaderFooterLayoutResult[] | undefined; @@ -626,6 +976,8 @@ export async function incrementalLayout( dirty, headers, footers, + extraBlocks, + extraMeasures, }; } diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 84fe80d43..54b1183a1 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -130,6 +130,19 @@ export type LayoutOptions = { columns?: ColumnLayout; remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure; sectionMetadata?: SectionMetadata[]; + /** + * Extra bottom margin per page index (0-based) reserved for non-body content + * rendered at the bottom of the page (e.g., footnotes). + * + * When provided, the paginator will shrink the body content area on that page by + * increasing the effective bottom margin for that page only. + */ + footnoteReservedByPageIndex?: number[]; + /** + * Optional footnote metadata consumed by higher-level orchestration (e.g. layout-bridge). + * The core layout engine does not interpret this field directly. + */ + footnotes?: unknown; /** * Actual measured header content heights per variant type. * When provided, the layout engine will ensure body content starts below @@ -679,7 +692,13 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const paginator = createPaginator({ margins: paginatorMargins, getActiveTopMargin: () => activeTopMargin, - getActiveBottomMargin: () => activeBottomMargin, + getActiveBottomMargin: () => { + const reserves = options.footnoteReservedByPageIndex; + const pageIndex = Math.max(0, pageCount - 1); + const reserve = Array.isArray(reserves) ? reserves[pageIndex] : 0; + const reservePx = typeof reserve === 'number' && Number.isFinite(reserve) && reserve > 0 ? reserve : 0; + return activeBottomMargin + reservePx; + }, getActiveHeaderDistance: () => activeHeaderDistance, getActiveFooterDistance: () => activeFooterDistance, getActivePageSize: () => activePageSize, diff --git a/packages/layout-engine/pm-adapter/src/constants.ts b/packages/layout-engine/pm-adapter/src/constants.ts index 663895921..bf485528b 100644 --- a/packages/layout-engine/pm-adapter/src/constants.ts +++ b/packages/layout-engine/pm-adapter/src/constants.ts @@ -135,6 +135,7 @@ export const ATOMIC_INLINE_TYPES = new Set([ 'lineBreak', 'page-number', 'total-page-number', + 'footnoteReference', 'passthroughInline', 'bookmarkEnd', ]); diff --git a/packages/layout-engine/pm-adapter/src/converter-context.d.ts b/packages/layout-engine/pm-adapter/src/converter-context.d.ts index e8cf6fc23..4ede1ec4c 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.d.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.d.ts @@ -22,6 +22,12 @@ export type ConverterContext = { docx?: Record; numbering?: ConverterNumberingContext; linkedStyles?: ConverterLinkedStyle[]; + /** + * Optional mapping from OOXML footnote id -> display number. + * Display numbers are assigned in order of first appearance in the document (1-based), + * matching Word's visible numbering behavior even when ids are non-contiguous or start at 0. + */ + footnoteNumberById?: Record; }; /** * Guard that checks whether the converter context includes DOCX data diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index 4f74d7bcb..1b779201e 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -35,6 +35,12 @@ export type ConverterContext = { docx?: Record; numbering?: ConverterNumberingContext; linkedStyles?: ConverterLinkedStyle[]; + /** + * Optional mapping from OOXML footnote id -> display number. + * Display numbers are assigned in order of first appearance in the document (1-based), + * matching Word's visible numbering behavior even when ids are non-contiguous or start at 0. + */ + footnoteNumberById?: Record; /** * Paragraph properties inherited from the containing table's style. * Per OOXML spec, table styles can define pPr that applies to all diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index c44e97adf..8c44f5ed8 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -925,6 +925,33 @@ export function paragraphToFlowBlocks( let tabOrdinal = 0; let suppressedByVanish = false; + const toSuperscriptDigits = (value: unknown): string => { + const map: Record = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + }; + return String(value ?? '') + .split('') + .map((ch) => map[ch] ?? ch) + .join(''); + }; + + const resolveFootnoteDisplayNumber = (id: unknown): unknown => { + const key = id == null ? null : String(id); + if (!key) return null; + const mapping = converterContext?.footnoteNumberById; + const mapped = mapping && typeof mapping === 'object' ? (mapping as Record)[key] : undefined; + return typeof mapped === 'number' && Number.isFinite(mapped) && mapped > 0 ? mapped : null; + }; + const nextId = () => (partIndex === 0 ? baseBlockId : `${baseBlockId}-${partIndex}`); const attachAnchorParagraphId = (block: T, anchorParagraphId: string): T => { const applicableKinds = new Set(['drawing', 'image', 'table']); @@ -983,10 +1010,43 @@ export function paragraphToFlowBlocks( activeRunProperties?: Record | null, activeHidden = false, ) => { + if (node.type === 'footnoteReference') { + const mergedMarks = [...(node.marks ?? []), ...(inheritedMarks ?? [])]; + const refPos = positions.get(node); + const id = (node.attrs as Record | undefined)?.id; + const displayId = resolveFootnoteDisplayNumber(id) ?? id ?? '*'; + const displayText = toSuperscriptDigits(displayId); + + const run = textNodeToRun( + { type: 'text', text: displayText } as PMNode, + positions, + defaultFont, + defaultSize, + [], // marks applied after linked styles/base defaults + activeSdt, + hyperlinkConfig, + themeColors, + ); + const inlineStyleId = getInlineStyleId(mergedMarks); + applyRunStyles(run, inlineStyleId, activeRunStyleId); + applyBaseRunDefaults(run, baseRunDefaults, defaultFont, defaultSize); + applyMarksToRun(run, mergedMarks, hyperlinkConfig, themeColors); + + // Copy PM positions from the parent footnoteReference node + if (refPos) { + run.pmStart = refPos.start; + run.pmEnd = refPos.end; + } + + currentRuns.push(run); + return; + } + if (activeHidden && node.type !== 'run') { suppressedByVanish = true; return; } + if (node.type === 'text' && node.text) { // Apply styles in correct priority order: // 1. Create run with defaults (lowest priority) - textNodeToRun with empty marks diff --git a/packages/layout-engine/pm-adapter/src/index.ts b/packages/layout-engine/pm-adapter/src/index.ts index 8007713c1..2d14f7c31 100644 --- a/packages/layout-engine/pm-adapter/src/index.ts +++ b/packages/layout-engine/pm-adapter/src/index.ts @@ -31,6 +31,7 @@ export type { PMDocumentMap, BatchAdapterOptions, FlowBlocksResult, + ConverterContext } from './types.js'; // Re-export enum as value diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index 2cffa1faa..5b806eed6 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -334,6 +334,14 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Presentation Editor Remote Cursors - end */ +/* Footnotes */ +.sd-footnote-ref { + font-size: 0.75em; + line-height: 1; + vertical-align: super; + user-select: none; +} + /* Image placeholder */ .ProseMirror placeholder { display: inline; diff --git a/packages/super-editor/src/core/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/core/PresentationEditor.footnotesPmMarkers.test.ts new file mode 100644 index 000000000..6c5b2bebe --- /dev/null +++ b/packages/super-editor/src/core/PresentationEditor.footnotesPmMarkers.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { PresentationEditor } from './PresentationEditor'; + +let capturedLayoutOptions: any; + +vi.mock('./Editor', () => ({ + Editor: vi.fn().mockImplementation(() => ({ + on: vi.fn(), + off: vi.fn(), + destroy: vi.fn(), + setDocumentMode: vi.fn(), + setOptions: vi.fn(), + getJSON: vi.fn(() => ({ type: 'doc', content: [] })), + isEditable: true, + schema: {}, + state: { + selection: { from: 0, to: 0 }, + doc: { + nodeSize: 100, + content: { size: 100 }, + descendants: vi.fn((cb: (node: any, pos: number) => void) => { + cb({ type: { name: 'footnoteReference' }, attrs: { id: '1' }, nodeSize: 1 }, 10); + }), + }, + }, + view: { dom: document.createElement('div'), hasFocus: vi.fn(() => false) }, + options: { documentId: 'test', element: document.createElement('div'), mediaFiles: {} }, + converter: { + headers: {}, + footers: {}, + headerIds: { default: null, ids: [] }, + footerIds: { default: null, ids: [] }, + footnotes: [{ id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] }], + }, + storage: { image: { media: {} } }, + })), +})); + +vi.mock('@superdoc/pm-adapter', () => ({ + toFlowBlocks: vi.fn((_: unknown, opts?: any) => { + if (typeof opts?.blockIdPrefix === 'string' && opts.blockIdPrefix.startsWith('footnote-')) { + return { + blocks: [{ kind: 'paragraph', runs: [{ kind: 'text', text: 'Body', pmStart: 5, pmEnd: 9 }] }], + bookmarks: new Map(), + }; + } + return { blocks: [], bookmarks: new Map() }; + }), +})); + +vi.mock('@superdoc/layout-bridge', () => ({ + incrementalLayout: vi.fn(async (...args: any[]) => { + capturedLayoutOptions = args[3]; + return { layout: { pages: [] }, measures: [] }; + }), + selectionToRects: vi.fn(() => []), + clickToPosition: vi.fn(), + getFragmentAtPosition: vi.fn(), + computeLinePmRange: vi.fn(), + measureCharacterX: vi.fn(), + extractIdentifierFromConverter: vi.fn(), + getHeaderFooterType: vi.fn(), + getBucketForPageNumber: vi.fn(), + getBucketRepresentative: vi.fn(), + buildMultiSectionIdentifier: vi.fn(), + getHeaderFooterTypeForSection: vi.fn(), + layoutHeaderFooterWithCache: vi.fn(), + computeDisplayPageNumber: vi.fn(), + findWordBoundaries: vi.fn(), + findParagraphBoundaries: vi.fn(), + createDragHandler: vi.fn(), + PageGeometryHelper: vi.fn(() => ({ + updateLayout: vi.fn(), + getPageIndexAtY: vi.fn(() => 0), + getNearestPageIndex: vi.fn(() => 0), + getPageTop: vi.fn(() => 0), + getPageGap: vi.fn(() => 0), + getLayout: vi.fn(() => ({ pages: [] })), + })), +})); + +vi.mock('@superdoc/painter-dom', () => ({ + createDomPainter: vi.fn(() => ({ + paint: vi.fn(), + destroy: vi.fn(), + setZoom: vi.fn(), + setLayoutMode: vi.fn(), + setProviders: vi.fn(), + setData: vi.fn(), + })), + DOM_CLASS_NAMES: { PAGE: '', FRAGMENT: '', LINE: '', INLINE_SDT_WRAPPER: '', BLOCK_SDT: '', DOCUMENT_SECTION: '' }, +})); + +vi.mock('@superdoc/measuring-dom', () => ({ measureBlock: vi.fn(() => ({ width: 100, height: 100 })) })); + +vi.mock('./header-footer/HeaderFooterRegistry', () => ({ + HeaderFooterEditorManager: vi.fn(() => ({ + createEditor: vi.fn(), + destroyEditor: vi.fn(), + getEditor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + destroy: vi.fn(), + })), + HeaderFooterLayoutAdapter: vi.fn(() => ({ + clear: vi.fn(), + getBatch: vi.fn(() => []), + getBlocksByRId: vi.fn(() => new Map()), + })), +})); + +vi.mock('./header-footer/EditorOverlayManager', () => ({ + EditorOverlayManager: vi.fn(() => ({ + showEditingOverlay: vi.fn(() => ({ success: true, editorHost: document.createElement('div') })), + hideEditingOverlay: vi.fn(), + showSelectionOverlay: vi.fn(), + hideSelectionOverlay: vi.fn(), + setOnDimmingClick: vi.fn(), + getActiveEditorHost: vi.fn(() => null), + destroy: vi.fn(), + })), +})); + +vi.mock('y-prosemirror', () => ({ + ySyncPluginKey: { getState: vi.fn(() => ({ type: {}, binding: { mapping: new Map() } })) }, + absolutePositionToRelativePosition: vi.fn((pos) => ({ type: 'relative', pos })), + relativePositionToAbsolutePosition: vi.fn((relPos) => relPos?.pos ?? null), +})); + +describe('PresentationEditor - footnote number marker PM position', () => { + let editor: PresentationEditor; + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + capturedLayoutOptions = undefined; + }); + + afterEach(() => { + editor?.destroy(); + document.body.removeChild(container); + vi.clearAllMocks(); + }); + + it('adds pmStart/pmEnd to the data-sd-footnote-number marker run', async () => { + editor = new PresentationEditor({ element: container }); + await new Promise((r) => setTimeout(r, 100)); + + const footnotes = capturedLayoutOptions?.footnotes; + expect(footnotes).toBeTruthy(); + const blocks = footnotes.blocksById?.get('1'); + expect(blocks?.[0]?.kind).toBe('paragraph'); + + const markerRun = blocks?.[0]?.runs?.[0]; + expect(markerRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true'); + expect(markerRun?.pmStart).toBe(5); + expect(markerRun?.pmEnd).toBe(6); + }); +}); diff --git a/packages/super-editor/src/core/PresentationEditor.ts b/packages/super-editor/src/core/PresentationEditor.ts index f2e03b367..f0023ba58 100644 --- a/packages/super-editor/src/core/PresentationEditor.ts +++ b/packages/super-editor/src/core/PresentationEditor.ts @@ -66,7 +66,7 @@ import { initHeaderFooterRegistry as initHeaderFooterRegistryFromHelper } from ' import { decodeRPrFromMarks } from './super-converter/styles.js'; import { halfPointToPoints } from './super-converter/helpers.js'; import { layoutPerRIdHeaderFooters as layoutPerRIdHeaderFootersFromHelper } from './header-footer/HeaderFooterPerRidLayout.js'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks, ConverterContext } from '@superdoc/pm-adapter'; import { incrementalLayout, selectionToRects, @@ -283,6 +283,10 @@ interface EditorWithConverter extends Editor { footerIds?: { default?: string; first?: string; even?: string; odd?: string }; createDefaultHeader?: (variant: string) => string; createDefaultFooter?: (variant: string) => string; + footnotes?: Array<{ + id: string; + content?: unknown[]; + }>; }; } @@ -351,6 +355,15 @@ type LayoutState = { anchorMap?: Map; }; +type FootnoteReference = { id: string; pos: number }; +type FootnotesLayoutInput = { + refs: FootnoteReference[]; + blocksById: Map; + gap?: number; + topPadding?: number; + dividerHeight?: number; +}; + type LayoutMetrics = { durationMs: number; blockCount: number; @@ -4383,13 +4396,39 @@ export class PresentationEditor extends EventEmitter { const sectionMetadata: SectionMetadata[] = []; let blocks: FlowBlock[] | undefined; let bookmarks: Map = new Map(); + let converterContext: ConverterContext | undefined = undefined; try { const converter = (this.#editor as Editor & { converter?: Record }).converter; - const converterContext = converter + // Compute visible footnote numbering (1-based) by first appearance in the document. + // This matches Word behavior even when OOXML ids are non-contiguous or start at 0. + const footnoteNumberById: Record = {}; + try { + const seen = new Set(); + let counter = 1; + this.#editor?.state?.doc?.descendants?.((node: any) => { + if (node?.type?.name !== 'footnoteReference') return; + const rawId = node?.attrs?.id; + if (rawId == null) return; + const key = String(rawId); + if (!key || seen.has(key)) return; + seen.add(key); + footnoteNumberById[key] = counter; + counter += 1; + }); + } catch {} + // Expose numbering to node views and layout adapter. + try { + if (converter && typeof converter === 'object') { + converter['footnoteNumberById'] = footnoteNumberById; + } + } catch {} + + converterContext = converter ? { docx: converter.convertedXml, numbering: converter.numbering, linkedStyles: converter.linkedStyles, + ...(Object.keys(footnoteNumberById).length ? { footnoteNumberById } : {}), } : undefined; const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null); @@ -4422,7 +4461,14 @@ export class PresentationEditor extends EventEmitter { return; } - const layoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata); + const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata); + const footnotesLayoutInput = this.#buildFootnotesLayoutInput({ + converterContext, + themeColors: this.#editor?.converter?.themeColors ?? undefined, + }); + const layoutOptions = footnotesLayoutInput + ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } + : baseLayoutOptions; const previousBlocks = this.#layoutState.blocks; const previousLayout = this.#layoutState.layout; @@ -4430,6 +4476,8 @@ export class PresentationEditor extends EventEmitter { let measures: Measure[]; let headerLayouts: HeaderFooterLayoutResult[] | undefined; let footerLayouts: HeaderFooterLayoutResult[] | undefined; + let extraBlocks: FlowBlock[] | undefined; + let extraMeasures: Measure[] | undefined; const headerFooterInput = this.#buildHeaderFooterInput(); try { const result = await incrementalLayout( @@ -4456,6 +4504,8 @@ export class PresentationEditor extends EventEmitter { } ({ layout, measures } = result); + extraBlocks = Array.isArray(result.extraBlocks) ? result.extraBlocks : undefined; + extraMeasures = Array.isArray(result.extraMeasures) ? result.extraMeasures : undefined; // Add pageGap to layout for hit testing to account for gaps between rendered pages. // Gap depends on virtualization mode and must be non-negative. layout.pageGap = this.#getEffectivePageGap(); @@ -4532,7 +4582,13 @@ export class PresentationEditor extends EventEmitter { footerMeasures.push(...rIdResult.measures); } - // Pass all blocks (main document + headers + footers) to the painter + // Merge any extra lookup blocks (e.g., footnotes injected into page fragments) + if (extraBlocks && extraMeasures && extraBlocks.length === extraMeasures.length && extraBlocks.length > 0) { + footerBlocks.push(...extraBlocks); + footerMeasures.push(...extraMeasures); + } + + // Pass all blocks (main document + headers + footers + extras) to the painter painter.setData?.( blocks, measures, @@ -4579,6 +4635,7 @@ export class PresentationEditor extends EventEmitter { this.emit('commentPositions', { positions: commentPositions }); } } + if (this.#telemetryEmitter && metrics) { this.#telemetryEmitter({ type: 'layout', data: { layout, blocks, measures, metrics } }); } @@ -4808,6 +4865,165 @@ export class PresentationEditor extends EventEmitter { }; } + #buildFootnotesLayoutInput({ + converterContext, + themeColors, + }: { + converterContext: ConverterContext | undefined; + themeColors: unknown; + }): FootnotesLayoutInput | null { + const footnoteNumberById = converterContext?.footnoteNumberById; + + const toSuperscriptDigits = (value: unknown): string => { + const map: Record = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + }; + const str = String(value ?? ''); + return str + .split('') + .map((ch) => map[ch] ?? ch) + .join(''); + }; + + const ensureFootnoteMarker = (blocks: FlowBlock[], id: string): void => { + const displayNumberRaw = + footnoteNumberById && typeof footnoteNumberById === 'object' ? footnoteNumberById[id] : undefined; + const displayNumber = + typeof displayNumberRaw === 'number' && Number.isFinite(displayNumberRaw) && displayNumberRaw > 0 + ? displayNumberRaw + : 1; + const firstParagraph = blocks.find((b) => b?.kind === 'paragraph') as + | (FlowBlock & { kind: 'paragraph'; runs?: Array> }) + | undefined; + if (!firstParagraph) return; + const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; + const markerText = toSuperscriptDigits(displayNumber); + + const baseRun = runs.find((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + if (dataAttrs?.['data-sd-footnote-number']) return false; + const pmStart = (r as { pmStart?: unknown }).pmStart; + const pmEnd = (r as { pmEnd?: unknown }).pmEnd; + return ( + typeof pmStart === 'number' && + Number.isFinite(pmStart) && + typeof pmEnd === 'number' && + Number.isFinite(pmEnd) + ); + }) as { pmStart: number; pmEnd: number } | undefined; + + const markerPmStart = baseRun?.pmStart ?? null; + const markerPmEnd = + markerPmStart != null + ? baseRun?.pmEnd != null + ? Math.max(markerPmStart, Math.min(baseRun.pmEnd, markerPmStart + markerText.length)) + : markerPmStart + markerText.length + : null; + + const alreadyHasMarker = runs.some((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + return Boolean(dataAttrs?.['data-sd-footnote-number']); + }); + if (alreadyHasMarker) { + if (markerPmStart != null && markerPmEnd != null) { + const markerRun = runs.find((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + return Boolean(dataAttrs?.['data-sd-footnote-number']); + }) as { pmStart?: number | null; pmEnd?: number | null } | undefined; + if (markerRun) { + if (markerRun.pmStart == null) markerRun.pmStart = markerPmStart; + if (markerRun.pmEnd == null) markerRun.pmEnd = markerPmEnd; + } + } + return; + } + + const firstTextRun = runs.find((r) => typeof (r as { text?: unknown }).text === 'string') as + | { fontFamily?: unknown; fontSize?: unknown; color?: unknown; text?: unknown } + | undefined; + + const markerRun: Record = { + kind: 'text', + text: markerText, + dataAttrs: { + 'data-sd-footnote-number': 'true', + }, + ...(markerPmStart != null ? { pmStart: markerPmStart } : {}), + ...(markerPmEnd != null ? { pmEnd: markerPmEnd } : {}), + }; + markerRun.fontFamily = typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : 'Arial'; + markerRun.fontSize = + typeof firstTextRun?.fontSize === 'number' && Number.isFinite(firstTextRun.fontSize) ? firstTextRun.fontSize : 12; + if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; + + // Insert marker at the very start. + runs.unshift(markerRun); + + firstParagraph.runs = runs; + }; + + const state = this.#editor?.state; + if (!state) return null; + + const converter = (this.#editor as Partial)?.converter; + const importedFootnotes = Array.isArray(converter?.footnotes) ? converter.footnotes : []; + if (importedFootnotes.length === 0) return null; + + const refs: FootnoteReference[] = []; + const idsInUse = new Set(); + state.doc.descendants((node, pos) => { + if (node.type?.name !== 'footnoteReference') return; + const id = node.attrs?.id; + if (id == null) return; + const key = String(id); + const insidePos = Math.min(pos + 1, state.doc.content.size); + refs.push({ id: key, pos: insidePos }); + idsInUse.add(key); + }); + if (refs.length === 0) return null; + + const blocksById = new Map(); + idsInUse.forEach((id) => { + const entry = importedFootnotes.find((f) => String(f?.id) === id); + const content = entry?.content; + if (!Array.isArray(content) || content.length === 0) return; + + try { + const clonedContent = JSON.parse(JSON.stringify(content)); + const footnoteDoc = { type: 'doc', content: clonedContent }; + const result = toFlowBlocks(footnoteDoc, { + blockIdPrefix: `footnote-${id}-`, + enableRichHyperlinks: true, + themeColors: themeColors as never, + converterContext: converterContext as never, + }); + if (result?.blocks?.length) { + ensureFootnoteMarker(result.blocks, id); + blocksById.set(id, result.blocks); + } + } catch {} + }); + + if (blocksById.size === 0) return null; + + return { + refs, + blocksById, + gap: 2, + topPadding: 4, + dividerHeight: 1, + }; + } + #buildHeaderFooterInput() { if (!this.#headerFooterAdapter) { return null; @@ -5098,7 +5314,9 @@ export class PresentationEditor extends EventEmitter { const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; const margins = pageMargins ?? layout.pages[0]?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; - const box = this.#computeDecorationBox(kind, margins, pageHeight); + const decorationMargins = + kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; + const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); // Use helper to compute metrics with type safety and consistent logic const rawLayoutHeight = rIdLayout.layout.height ?? 0; @@ -5160,12 +5378,13 @@ export class PresentationEditor extends EventEmitter { const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; const margins = pageMargins ?? layout.pages[0]?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; - const box = this.#computeDecorationBox(kind, margins, pageHeight); + const decorationMargins = + kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; + const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); // Use helper to compute metrics with type safety and consistent logic const rawLayoutHeight = variant.layout.height ?? 0; const metrics = this.#computeHeaderFooterMetrics(kind, rawLayoutHeight, box, pageHeight, margins.footer ?? 0); - const fallbackId = this.#headerFooterManager?.getVariantId(kind, headerFooterType); const finalHeaderId = sectionRId ?? fallbackId ?? undefined; @@ -5275,6 +5494,19 @@ export class PresentationEditor extends EventEmitter { } } + #stripFootnoteReserveFromBottomMargin(pageMargins: PageMargins, page?: Page | null): PageMargins { + const reserveRaw = (page as Page | null | undefined)?.footnoteReserved; + const reserve = typeof reserveRaw === 'number' && Number.isFinite(reserveRaw) && reserveRaw > 0 ? reserveRaw : 0; + if (!reserve) return pageMargins; + + const bottomRaw = pageMargins.bottom; + const bottom = typeof bottomRaw === 'number' && Number.isFinite(bottomRaw) ? bottomRaw : 0; + const nextBottom = Math.max(0, bottom - reserve); + if (nextBottom === bottom) return pageMargins; + + return { ...pageMargins, bottom: nextBottom }; + } + /** * Computes the expected header/footer section type for a page based on document configuration. * @@ -5364,7 +5596,8 @@ export class PresentationEditor extends EventEmitter { // Same for footer - always create a hit region const footerPayload = this.#footerDecorationProvider?.(page.number, margins, page); - const footerBox = this.#computeDecorationBox('footer', margins, actualPageHeight); + const footerBoxMargins = this.#stripFootnoteReserveFromBottomMargin(margins, page); + const footerBox = this.#computeDecorationBox('footer', footerBoxMargins, actualPageHeight); this.#footerRegions.set(pageIndex, { kind: 'footer', headerId: footerPayload?.headerId, diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 203207241..25c1272e1 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -184,6 +184,7 @@ class SuperConverter { this.addedMedia = {}; this.comments = []; + this.footnotes = []; this.inlineDocumentFonts = []; // Store custom highlight colors @@ -924,6 +925,7 @@ class SuperConverter { this.pageStyles = result.pageStyles; this.numbering = result.numbering; this.comments = result.comments; + this.footnotes = result.footnotes; this.linkedStyles = result.linkedStyles; this.inlineDocumentFonts = result.inlineDocumentFonts; this.themeColors = result.themeColors ?? null; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js new file mode 100644 index 000000000..ff82a12f5 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js @@ -0,0 +1,92 @@ +import { defaultNodeListHandler } from './docxImporter'; + +/** + * Remove w:footnoteRef placeholders from converted footnote content. + * In OOXML footnotes, the first run often includes a w:footnoteRef marker which + * Word uses to render the footnote number. We render numbering ourselves. + * + * @param {Array} nodes + * @returns {Array} + */ +const stripFootnoteMarkerNodes = (nodes) => { + if (!Array.isArray(nodes) || nodes.length === 0) return nodes; + const walk = (list) => { + if (!Array.isArray(list) || list.length === 0) return; + for (let i = list.length - 1; i >= 0; i--) { + const node = list[i]; + if (!node) continue; + if (node.type === 'passthroughInline' && node.attrs?.originalName === 'w:footnoteRef') { + list.splice(i, 1); + continue; + } + if (Array.isArray(node.content)) { + walk(node.content); + } + } + }; + const copy = JSON.parse(JSON.stringify(nodes)); + walk(copy); + return copy; +}; + +/** + * Parse footnotes.xml into SuperDoc-ready footnote entries. + * + * These will be available on converter.footnotes and are used by PresentationEditor + * to build a footnotes panel. + * + * @param {Object} params + * @param {ParsedDocx} params.docx The parsed docx object + * @param {NodeListHandler} [params.nodeListHandler] Optional node list handler (defaults to docxImporter default) + * @param {SuperConverter} params.converter The super converter instance + * @param {Editor} params.editor The editor instance + * @param {Object} [params.numbering] Numbering definitions (optional) + * @returns {Array<{id: string, content: any[]}>} + */ +export function importFootnoteData({ docx, editor, converter, nodeListHandler, numbering } = {}) { + const handler = nodeListHandler || defaultNodeListHandler(); + const footnotes = docx?.['word/footnotes.xml']; + if (!footnotes?.elements?.length) return []; + + const root = footnotes.elements[0]; + const elements = Array.isArray(root?.elements) ? root.elements : []; + const footnoteElements = elements.filter((el) => el?.name === 'w:footnote'); + if (footnoteElements.length === 0) return []; + + const results = []; + const lists = {}; + const inlineDocumentFonts = []; + footnoteElements.forEach((el) => { + const idRaw = el?.attributes?.['w:id']; + if (idRaw === undefined || idRaw === null) return; + const id = String(idRaw); + const idNumber = Number(id); + // Skip special footnotes by explicit type (Word uses these for separators). + const type = el?.attributes?.['w:type']; + if (type === 'separator' || type === 'continuationSeparator') return; + // Be permissive about ids: some producers emit footnotes starting at 0. + // Only skip negative ids (Word uses -1 for separator). + if (!Number.isFinite(idNumber) || idNumber < 0) return; + + const childElements = Array.isArray(el.elements) ? el.elements : []; + const converted = handler.handler({ + nodes: childElements, + nodeListHandler: handler, + docx, + editor, + converter, + numbering, + lists, + inlineDocumentFonts, + path: [el], + }); + + const stripped = stripFootnoteMarkerNodes(converted); + results.push({ + id, + content: stripped, + }); + }); + + return results; +} diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 7fd5770ca..394ef057c 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -18,9 +18,11 @@ import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumbe import { pageReferenceEntity } from './pageReferenceImporter.js'; import { pictNodeHandlerEntity } from './pictNodeImporter.js'; import { importCommentData } from './documentCommentsImporter.js'; +import { importFootnoteData } from './documentFootnotesImporter.js'; import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js'; import { pruneIgnoredNodes } from './ignoredNodes.js'; import { tabNodeEntityHandler } from './tabImporter.js'; +import { footnoteReferenceHandlerEntity } from './footnoteReferenceImporter.js'; import { tableNodeHandlerEntity } from './tableImporter.js'; import { tableOfContentsHandlerEntity } from './tableOfContentsImporter.js'; import { preProcessNodesForFldChar } from '../../field-references'; @@ -113,13 +115,14 @@ export const createDocumentJson = (docx, converter, editor) => { const contentElements = node.elements?.filter((n) => n.name !== 'w:sectPr') ?? []; const content = pruneIgnoredNodes(contentElements); - const comments = importCommentData({ docx, nodeListHandler, converter, editor }); // Track imported lists const lists = {}; const inlineDocumentFonts = []; const numbering = getNumberingDefinitions(docx); + const comments = importCommentData({ docx, nodeListHandler, converter, editor }); + const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); let parsedContent = nodeListHandler.handler({ nodes: content, nodeListHandler, @@ -159,6 +162,7 @@ export const createDocumentJson = (docx, converter, editor) => { savedTagsToRestore: node, pageStyles: getDocumentStyles(node, docx, converter, editor, numbering), comments, + footnotes, inlineDocumentFonts, linkedStyles: getStyleDefinitions(docx, converter, editor), numbering: getNumberingDefinitions(docx, converter), @@ -185,6 +189,7 @@ export const defaultNodeListHandler = () => { drawingNodeHandlerEntity, trackChangeNodeHandlerEntity, tableNodeHandlerEntity, + footnoteReferenceHandlerEntity, tabNodeEntityHandler, tableOfContentsHandlerEntity, autoPageHandlerEntity, @@ -709,6 +714,7 @@ export function filterOutRootInlineNodes(content = []) { 'commentRangeStart', 'commentRangeEnd', 'commentReference', + 'footnoteReference', 'structuredContent', ]); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/footnoteReferenceImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/footnoteReferenceImporter.js new file mode 100644 index 000000000..677b3340e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/footnoteReferenceImporter.js @@ -0,0 +1,8 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator } from '../../v3/handlers/w/footnoteReference/footnoteReference-translator.js'; + +/** + * @type {import("./docxImporter").NodeHandlerEntry} + */ +export const footnoteReferenceHandlerEntity = generateV2HandlerEntity('footnoteReferenceHandler', translator); + diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/index.js index 34e137072..814b2e2d2 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/index.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/index.js @@ -24,6 +24,7 @@ import { commentRangeStartTranslator as w_commentRangeStart_translator, commentRangeEndTranslator as w_commentRangeEnd_translator, } from './w/commentRange/comment-range-translator.js'; +import { translator as w_footnoteReference_translator } from './w/footnoteReference/footnoteReference-translator.js'; import { translator as w_contextualSpacing } from './w/contextualSpacing/contextualSpacing-translator.js'; import { translator as w_cs } from './w/cs/cs-translator.js'; import { translator as w_del_translator } from './w/del/del-translator.js'; @@ -188,6 +189,7 @@ const translatorList = Array.from( w_em_translator, w_emboss_translator, w_end_translator, + w_footnoteReference_translator, w_fitText_translator, w_framePr_translator, w_gridAfter_translator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js new file mode 100644 index 000000000..ed9254362 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js @@ -0,0 +1,2 @@ +export { attrConfig as idAttrConfig } from './w-id.js'; + diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-id.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-id.js new file mode 100644 index 000000000..7a569dd4a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-id.js @@ -0,0 +1,30 @@ +// @ts-check + +/** + * Encoder for the 'w:id' attribute on the element. + * Maps to the 'id' attribute in SuperDoc. + * @param {Record} attributes + * @returns {string|undefined} + */ +export const encode = (attributes) => { + return attributes?.['w:id']; +}; + +/** + * Decoder for the 'id' attribute in SuperDoc. + * Maps to the 'w:id' attribute in OOXML. + * @param {Record} attrs + * @returns {string|undefined} + */ +export const decode = (attrs) => { + return attrs?.id; +}; + +/** @type {import('@translator').AttrConfig} */ +export const attrConfig = Object.freeze({ + xmlName: 'w:id', + sdName: 'id', + encode, + decode, +}); + diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js new file mode 100644 index 000000000..43346d8b0 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js @@ -0,0 +1,53 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { idAttrConfig } from './attributes/index.js'; + +/** @type {import('@translator').XmlNodeName} */ +const XML_NODE_NAME = 'w:footnoteReference'; + +/** @type {import('@translator').SuperDocNodeOrKeyName} */ +const SD_NODE_NAME = 'footnoteReference'; + +/** + * Encode as an inline atom node. + * @param {import('@translator').SCEncoderConfig} _ + * @param {import('@translator').EncodedAttributes} [encodedAttrs] + * @returns {import('@translator').SCEncoderResult} + */ +const encode = (_, encodedAttrs) => { + const translated = { type: SD_NODE_NAME }; + if (encodedAttrs && Object.keys(encodedAttrs).length > 0) { + translated.attrs = { ...encodedAttrs }; + } + return translated; +}; + +/** + * Decode SuperDoc footnoteReference back to OOXML . + * Note: This element must be emitted inside a by the parent run translator. + * + * @param {import('@translator').SCDecoderConfig} _params + * @param {import('@translator').DecodedAttributes} [decodedAttrs] + * @returns {import('@translator').SCDecoderResult} + */ +const decode = (_params, decodedAttrs) => { + const ref = { name: XML_NODE_NAME, elements: [] }; + if (decodedAttrs && Object.keys(decodedAttrs).length > 0) { + ref.attributes = { ...decodedAttrs }; + } + return ref; +}; + +/** @type {import('@translator').NodeTranslatorConfig} */ +export const config = { + xmlName: XML_NODE_NAME, + sdNodeOrKeyName: SD_NODE_NAME, + type: NodeTranslator.translatorTypes.NODE, + encode, + decode, + attributes: [idAttrConfig], +}; + +/** @type {import('@translator').NodeTranslator} */ +export const translator = NodeTranslator.from(config); + diff --git a/packages/super-editor/src/extensions/footnote/footnote.js b/packages/super-editor/src/extensions/footnote/footnote.js new file mode 100644 index 000000000..d05b64447 --- /dev/null +++ b/packages/super-editor/src/extensions/footnote/footnote.js @@ -0,0 +1,126 @@ +import { Node, Attribute } from '@core/index.js'; + +const toSuperscriptDigits = (value) => { + const map = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + }; + return String(value ?? '') + .split('') + .map((ch) => map[ch] ?? ch) + .join(''); +}; + +const resolveFootnoteDisplayNumber = (editor, id) => { + const key = id == null ? null : String(id); + if (!key) return null; + const map = editor?.converter?.footnoteNumberById; + const mapped = map && typeof map === 'object' ? map[key] : undefined; + return typeof mapped === 'number' && Number.isFinite(mapped) && mapped > 0 ? mapped : null; +}; + +export class FootnoteReferenceNodeView { + constructor(node, getPos, decorations, editor, htmlAttributes = {}) { + void decorations; + this.node = node; + this.getPos = getPos; + this.editor = editor; + this.dom = this.#renderDom(node, htmlAttributes); + } + + #renderDom(node, htmlAttributes) { + const el = document.createElement('sup'); + el.className = 'sd-footnote-ref'; + el.setAttribute('contenteditable', 'false'); + el.setAttribute('aria-label', 'Footnote reference'); + + Object.entries(htmlAttributes).forEach(([key, value]) => { + if (value != null && value !== false) { + el.setAttribute(key, String(value)); + } + }); + + const id = node?.attrs?.id; + if (id != null) { + el.setAttribute('data-footnote-id', String(id)); + const display = resolveFootnoteDisplayNumber(this.editor, id) ?? id; + el.textContent = toSuperscriptDigits(display); + } else { + el.textContent = '*'; + } + + return el; + } + + update(node) { + const incomingType = node?.type?.name; + const currentType = this.node?.type?.name; + if (!incomingType || incomingType !== currentType) return false; + this.node = node; + + const id = node?.attrs?.id; + if (id != null) { + this.dom.setAttribute('data-footnote-id', String(id)); + const display = resolveFootnoteDisplayNumber(this.editor, id) ?? id; + this.dom.textContent = toSuperscriptDigits(display); + } else { + this.dom.removeAttribute('data-footnote-id'); + this.dom.textContent = '*'; + } + + return true; + } +} + +export const FootnoteReference = Node.create({ + name: 'footnoteReference', + + group: 'inline', + + inline: true, + + atom: true, + + selectable: false, + + draggable: false, + + addOptions() { + return { + htmlAttributes: { + 'data-footnote-ref': 'true', + }, + }; + }, + + addAttributes() { + return { + id: { + default: null, + }, + }; + }, + + addNodeView() { + return ({ node, editor, getPos, decorations }) => { + const htmlAttributes = this.options.htmlAttributes; + return new FootnoteReferenceNodeView(node, getPos, decorations, editor, htmlAttributes); + }; + }, + + parseDOM() { + return [{ tag: 'sup[data-footnote-id]' }]; + }, + + renderDOM({ htmlAttributes }) { + return ['sup', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes)]; + }, +}); diff --git a/packages/super-editor/src/extensions/footnote/index.js b/packages/super-editor/src/extensions/footnote/index.js new file mode 100644 index 000000000..975681399 --- /dev/null +++ b/packages/super-editor/src/extensions/footnote/index.js @@ -0,0 +1,2 @@ +export * from './footnote.js'; + diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 83d76220f..f2a434bf1 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -26,6 +26,7 @@ import { Run } from './run/index.js'; import { Paragraph } from './paragraph/index.js'; import { Heading } from './heading/index.js'; import { CommentRangeStart, CommentRangeEnd, CommentReference, CommentsMark } from './comment/index.js'; +import { FootnoteReference } from './footnote/index.js'; import { TabNode } from './tab/index.js'; import { LineBreak, HardBreak } from './line-break/index.js'; import { Table } from './table/index.js'; @@ -122,6 +123,7 @@ const getStarterExtensions = () => { CommentRangeStart, CommentRangeEnd, CommentReference, + FootnoteReference, Document, FontFamily, FontSize, @@ -201,6 +203,7 @@ export { CommentRangeStart, CommentRangeEnd, CommentReference, + FootnoteReference, TabNode, LineBreak, HardBreak, diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index 4ce7c717c..9699f7d4e 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -1045,6 +1045,12 @@ export interface CommentReferenceAttrs extends InlineNodeAttributes { attributes?: Record; } +/** Footnote reference node attributes */ +export interface FootnoteReferenceAttrs extends InlineNodeAttributes { + /** Footnote id from OOXML (w:id) */ + id?: string | null; +} + // ============================================ // MODULE AUGMENTATION // ============================================ @@ -1087,6 +1093,7 @@ declare module '../../core/types/NodeAttributesMap.js' { commentRangeStart: CommentRangeStartAttrs; commentRangeEnd: CommentRangeEndAttrs; commentReference: CommentReferenceAttrs; + footnoteReference: FootnoteReferenceAttrs; // Permissions permStart: PermStartAttrs; diff --git a/packages/super-editor/src/tests/import/footnotesImporter.test.js b/packages/super-editor/src/tests/import/footnotesImporter.test.js new file mode 100644 index 000000000..ac1c8152a --- /dev/null +++ b/packages/super-editor/src/tests/import/footnotesImporter.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { createDocumentJson } from '@core/super-converter/v2/importer/docxImporter'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; + +const collectNodeTypes = (node, types = []) => { + if (!node) return types; + if (typeof node.type === 'string') types.push(node.type); + const content = Array.isArray(node.content) ? node.content : []; + content.forEach((child) => collectNodeTypes(child, types)); + return types; +}; + +const extractPlainText = (nodes) => { + if (!Array.isArray(nodes) || nodes.length === 0) return ''; + const parts = []; + const walk = (node) => { + if (!node) return; + if (node.type === 'text' && typeof node.text === 'string') { + parts.push(node.text); + return; + } + if (Array.isArray(node.content)) { + node.content.forEach(walk); + } + }; + nodes.forEach(walk); + return parts.join('').replace(/\s+/g, ' ').trim(); +}; + +describe('footnotes import', () => { + it('imports w:footnoteReference and loads matching footnotes.xml entry', () => { + const documentXml = + '' + + '' + + '' + + 'Hello' + + '' + + '' + + '' + + ''; + + const footnotesXml = + '' + + '' + + 'Footnote text' + + ''; + + const docx = { + 'word/document.xml': parseXmlToJson(documentXml), + 'word/footnotes.xml': parseXmlToJson(footnotesXml), + }; + + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + const result = createDocumentJson(docx, converter, editor); + expect(result).toBeTruthy(); + + expect(Array.isArray(result.footnotes)).toBe(true); + const footnote = result.footnotes.find((f) => f?.id === '1'); + expect(footnote).toBeTruthy(); + expect(Array.isArray(footnote.content)).toBe(true); + expect(extractPlainText(footnote.content)).toBe('Footnote text'); + + const types = collectNodeTypes(result.pmDoc); + expect(types).toContain('footnoteReference'); + }); +});