Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/super-editor/src/core/parts/init-parts-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { settingsPartDescriptor } from './adapters/settings-part-descriptor.js';
import { relsPartDescriptor } from './adapters/rels-part-descriptor.js';
import { numberingPartDescriptor } from './adapters/numbering-part-descriptor.js';
import { contentTypesPartDescriptor } from './adapters/content-types-part-descriptor.js';
import { footnotesPartDescriptor, endnotesPartDescriptor } from './adapters/notes-part-descriptor.js';
import { registerStaticInvalidationHandlers } from './invalidation/invalidation-handlers.js';
import { initRevision, trackRevisions } from '../../document-api-adapters/plan-engine/revision-tracker.js';

Expand All @@ -22,6 +23,8 @@ export function initPartsRuntime(editor: Editor): void {
registerPartDescriptor(relsPartDescriptor);
registerPartDescriptor(numberingPartDescriptor);
registerPartDescriptor(contentTypesPartDescriptor);
registerPartDescriptor(footnotesPartDescriptor);
registerPartDescriptor(endnotesPartDescriptor);
registerStaticInvalidationHandlers();
initRevision(editor);
trackRevisions(editor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ function handleHeaderFooterInvalidation(editor: Editor, _event: PartChangedEvent
}
}

// ---------------------------------------------------------------------------
// word/footnotes.xml and word/endnotes.xml
// ---------------------------------------------------------------------------

/**
* Dispatch a `forceUpdatePagination` transaction after a notes part mutation.
*
* Footnote/endnote body changes affect page flow (the note area expands or
* shrinks), so the layout engine must re-paginate.
*/
function handleNotesInvalidation(editor: Editor, _event: PartChangedEvent): void {
try {
const tr = editor.state.tr;
tr.setMeta('forceUpdatePagination', true);
editor.view?.dispatch?.(tr);
} catch {
// View may not be ready
}
}

// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
Expand All @@ -72,6 +92,8 @@ function handleHeaderFooterInvalidation(editor: Editor, _event: PartChangedEvent
export function registerStaticInvalidationHandlers(): void {
registerInvalidationHandler('word/numbering.xml', handleNumberingInvalidation);
registerInvalidationHandler('word/_rels/document.xml.rels', handleRelationshipsInvalidation);
registerInvalidationHandler('word/footnotes.xml', handleNotesInvalidation);
registerInvalidationHandler('word/endnotes.xml', handleNotesInvalidation);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ interface ConverterForSnapshot {
convertedXml?: Record<string, unknown>;
numbering?: unknown;
translatedNumbering?: unknown;
footnotes?: unknown;
endnotes?: unknown;
footnoteProperties?: unknown;
documentModified?: boolean;
documentGuid?: string | null;
}
Expand All @@ -37,6 +40,9 @@ interface CompoundSnapshot {
partEntries: Map<string, { existed: boolean; data: unknown }>;
numbering: unknown;
translatedNumbering: unknown;
footnotes: unknown;
endnotes: unknown;
footnoteProperties: unknown;
revision: string;
documentModified: boolean;
documentGuid: string | null;
Expand Down Expand Up @@ -69,6 +75,9 @@ function takeSnapshot(editor: Editor, partIds: Set<string>): CompoundSnapshot {
partEntries,
numbering: converter?.numbering ? clonePart(converter.numbering) : undefined,
translatedNumbering: converter?.translatedNumbering ? clonePart(converter.translatedNumbering) : undefined,
footnotes: converter?.footnotes ? clonePart(converter.footnotes) : undefined,
endnotes: converter?.endnotes ? clonePart(converter.endnotes) : undefined,
footnoteProperties: converter?.footnoteProperties ? clonePart(converter.footnoteProperties) : undefined,
revision: getRevision(editor),
documentModified: converter?.documentModified ?? false,
documentGuid: converter?.documentGuid ?? null,
Expand All @@ -95,6 +104,9 @@ function restoreFromSnapshot(editor: Editor, snapshot: CompoundSnapshot): void {

if (snapshot.numbering !== undefined) converter.numbering = snapshot.numbering;
if (snapshot.translatedNumbering !== undefined) converter.translatedNumbering = snapshot.translatedNumbering;
if (snapshot.footnotes !== undefined) converter.footnotes = snapshot.footnotes;
if (snapshot.endnotes !== undefined) converter.endnotes = snapshot.endnotes;
if (snapshot.footnoteProperties !== undefined) converter.footnoteProperties = snapshot.footnoteProperties;
converter.documentModified = snapshot.documentModified;
converter.documentGuid = snapshot.documentGuid;
restoreRevision(editor, snapshot.revision);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2776,6 +2776,20 @@ export class PresentationEditor extends EventEmitter {
handler: handleStylesDefaultsChanged as (...args: unknown[]) => void,
});

// Listen for footnote/endnote part mutations (e.g., insert via document API).
// These modify the OOXML part and derived cache but don't change the PM document,
// so the normal 'update' event won't trigger a layout refresh.
const handleNotesPartChanged = () => {
this.#pendingDocChange = true;
this.#selectionSync.onLayoutStart();
this.#scheduleRerender();
};
this.#editor.on('notes-part-changed', handleNotesPartChanged);
this.#editorListeners.push({
event: 'notes-part-changed',
handler: handleNotesPartChanged as (...args: unknown[]) => void,
});

const handleCollaborationReady = (payload: unknown) => {
this.emit('collaborationReady', payload);
// Setup remote cursor rendering after collaboration is ready
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { FlowBlock } from '@superdoc/contracts';
import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter';

import type { FootnoteReference, FootnotesLayoutInput } from '../types.js';
import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js';

// Re-export types for consumers
export type { FootnoteReference, FootnotesLayoutInput };
Expand Down Expand Up @@ -107,7 +108,7 @@ export function buildFootnotesInput(
const blocksById = new Map<string, FlowBlock[]>();

idsInUse.forEach((id) => {
const entry = importedFootnotes.find((f) => String(f?.id) === id);
const entry = findNoteEntryById(importedFootnotes, id);
const content = entry?.content;
if (!Array.isArray(content) || content.length === 0) return;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,24 @@ describe('buildFootnotesInput', () => {
expect(result?.blocksById.size).toBe(1);
});

it('renders the real note body when a special entry shares the same id', () => {
// Simulates the ID-collision scenario: continuationSeparator at id=1 (empty
// content) alongside a real note also at id=1 (with text). The builder
// must pick the regular note.
const editorState = createMockEditorState([{ id: '1', pos: 10 }]);
const converter = {
footnotes: [
{ id: '1', type: 'continuationSeparator', content: [] },
{ id: '1', type: null, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Real note' }] }] },
],
} as ConverterLike;

const result = buildFootnotesInput(editorState, converter, undefined, undefined);

expect(result).not.toBeNull();
expect(result?.blocksById.has('1')).toBe(true);
});

it('handles footnote ref with null id', () => {
const editorState = {
doc: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
prepareCommentsXmlFilesForExport,
} from './v2/exporter/commentsExporter.js';
import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js';
import { importFootnoteData, importEndnoteData } from './v2/importer/documentFootnotesImporter.js';
import { DocxHelpers } from './docx-helpers/index.js';
import { mergeRelationshipElements } from './relationship-helpers.js';
import { COMMENT_RELATIONSHIP_TYPES } from './constants.js';
Expand Down Expand Up @@ -1094,6 +1095,7 @@ class SuperConverter {
this.numbering = result.numbering;
this.comments = result.comments;
this.footnotes = result.footnotes;
this.endnotes = result.endnotes ?? [];
this.linkedStyles = result.linkedStyles;
this.translatedLinkedStyles = result.translatedLinkedStyles;
this.translatedNumbering = result.translatedNumbering;
Expand Down Expand Up @@ -1551,6 +1553,28 @@ class SuperConverter {
return { type: 'doc', content: [...schema] };
}

/**
* Re-import a notes part (footnotes.xml or endnotes.xml) from OOXML JSON
* to the derived NoteEntry[] cache.
*
* Used by the notes-part-descriptor afterCommit hook to rebuild
* `converter.footnotes` / `converter.endnotes` after a mutation.
*
* @param {string} partId - OOXML zip path ('word/footnotes.xml' or 'word/endnotes.xml')
* @returns {Array<{id: string, type?: string|null, content: any[], originalXml?: any}>}
*/
reimportNotePart(partId) {
if (!this.convertedXml?.[partId]) return [];

const importFn = partId === 'word/endnotes.xml' ? importEndnoteData : importFootnoteData;
return importFn({
docx: this.convertedXml,
editor: {},
converter: this,
numbering: this.numbering,
});
}

/**
* Creates a default empty header for the specified variant.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,33 +31,45 @@ const stripFootnoteMarkerNodes = (nodes) => {
};

/**
* Parse footnotes.xml into SuperDoc-ready footnote entries.
* Parse a notes part (footnotes.xml or endnotes.xml) into SuperDoc-ready note entries.
*
* These will be available on converter.footnotes and are used by PresentationEditor
* to build a footnotes panel.
* Shared implementation for both footnotes and endnotes. The only structural
* difference between the two OOXML parts is the element names
* (w:footnote vs w:endnote), which are parameterized via `childElementName`.
*
* @param {Object} params
* @param {ParsedDocx} params.docx The parsed docx object
* @param {NodeListHandler} [params.nodeListHandler] Optional node list handler (defaults to docxImporter default)
* @param {Object} params.partXml The parsed OOXML JSON for the notes part
* @param {string} params.childElementName 'w:footnote' or 'w:endnote'
* @param {string} params.filename Filename for import context (e.g. 'footnotes.xml')
* @param {ParsedDocx} params.docx The full parsed docx package
* @param {NodeListHandler} [params.nodeListHandler] Optional node list handler
* @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[]}>}
* @param {Editor} params.editor The editor instance
* @param {Object} [params.numbering] Numbering definitions (optional)
* @returns {Array<{id: string, type?: string|null, content: any[], originalXml?: any}>}
*/
export function importFootnoteData({ docx, editor, converter, nodeListHandler, numbering } = {}) {
function importNoteEntries({
partXml,
childElementName,
filename,
docx,
editor,
converter,
nodeListHandler,
numbering,
}) {
const handler = nodeListHandler || defaultNodeListHandler();
const footnotes = docx?.['word/footnotes.xml'];
if (!footnotes?.elements?.length) return [];
if (!partXml?.elements?.length) return [];

const root = footnotes.elements[0];
const root = partXml.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 noteElements = elements.filter((el) => el?.name === childElementName);
if (noteElements.length === 0) return [];

const results = [];
const lists = {};
const inlineDocumentFonts = [];
footnoteElements.forEach((el) => {
noteElements.forEach((el) => {
const idRaw = el?.attributes?.['w:id'];
if (idRaw === undefined || idRaw === null) return;
const id = String(idRaw);
Expand Down Expand Up @@ -93,7 +105,7 @@ export function importFootnoteData({ docx, editor, converter, nodeListHandler, n
numbering,
lists,
inlineDocumentFonts,
filename: 'footnotes.xml',
filename,
path: [el],
});

Expand All @@ -108,3 +120,52 @@ export function importFootnoteData({ docx, editor, converter, nodeListHandler, n

return results;
}

/**
* 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 } = {}) {
return importNoteEntries({
partXml: docx?.['word/footnotes.xml'],
childElementName: 'w:footnote',
filename: 'footnotes.xml',
docx,
editor,
converter,
nodeListHandler,
numbering,
});
}

/**
* Parse endnotes.xml into SuperDoc-ready endnote entries.
*
* Identical structure to footnotes but reads from word/endnotes.xml
* and filters for w:endnote elements.
*
* @param {Object} params - Same as importFootnoteData
* @returns {Array<{id: string, content: any[]}>}
*/
export function importEndnoteData({ docx, editor, converter, nodeListHandler, numbering } = {}) {
return importNoteEntries({
partXml: docx?.['word/endnotes.xml'],
childElementName: 'w:endnote',
filename: 'endnotes.xml',
docx,
editor,
converter,
nodeListHandler,
numbering,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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 { importFootnoteData, importEndnoteData } from './documentFootnotesImporter.js';
import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js';
import { pruneIgnoredNodes } from './ignoredNodes.js';
import { tabNodeEntityHandler } from './tabImporter.js';
Expand Down Expand Up @@ -150,6 +150,7 @@ export const createDocumentJson = (docx, converter, editor) => {
const numbering = getNumberingDefinitions(docx);
const comments = importCommentData({ docx, nodeListHandler, converter, editor });
const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering });
const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering });

const translatedLinkedStyles = translateStyleDefinitions(docx);
const translatedNumbering = translateNumberingDefinitions(docx);
Expand Down Expand Up @@ -201,6 +202,7 @@ export const createDocumentJson = (docx, converter, editor) => {
),
comments,
footnotes,
endnotes,
inlineDocumentFonts,
linkedStyles: getStyleDefinitions(docx, converter, editor),
translatedLinkedStyles,
Expand Down
Loading
Loading