Skip to content

Commit 45e7dcb

Browse files
committed
fix: update TOC creating extra spaces and changing fonts
1 parent 6a9edf2 commit 45e7dcb

5 files changed

Lines changed: 204 additions & 214 deletions

File tree

packages/super-editor/src/editors/v1/components/context-menu/menuItems.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -344,18 +344,18 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
344344
icon: ICONS.updateTableOfContents,
345345
isDefault: true,
346346
action: (editor, context) => {
347-
const tocId = context.tocAncestor?.sdBlockId;
348-
if (!tocId) return;
349-
const target = { kind: 'block', nodeType: 'tableOfContents', nodeId: tocId };
347+
const sdBlockId = context.tocAncestor?.sdBlockId;
348+
if (!sdBlockId) return;
350349
try {
351-
editor.doc?.toc?.update?.({ target, mode: 'all' });
350+
editor.doc?.toc?.update?.({
351+
target: { kind: 'block', nodeType: 'tableOfContents', nodeId: sdBlockId },
352+
mode: 'all',
353+
});
352354
} catch (error) {
353355
console.warn('[ContextMenu] toc.update failed:', error);
354356
}
355357
},
356-
showWhen: (context) => {
357-
return context.trigger === TRIGGERS.click && !!context.tocAncestor?.sdBlockId;
358-
},
358+
showWhen: (context) => context.trigger === TRIGGERS.click && !!context.tocAncestor?.sdBlockId,
359359
},
360360
],
361361
},

packages/super-editor/src/editors/v1/document-api-adapters/helpers/toc-entry-builder.ts

Lines changed: 62 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -66,46 +66,35 @@ export function collectTocSources(doc: ProseMirrorNode, config: TocSwitchConfig)
6666
const attrs = node.attrs as Record<string, unknown> | undefined;
6767
const paragraphProps = attrs?.paragraphProperties as Record<string, unknown> | undefined;
6868
const styleId = paragraphProps?.styleId as string | undefined;
69-
// Pasted/new paragraphs intentionally have null paraId/sdBlockId (see
70-
// InputRule.js SUPERDOC_SLICE_PASTE_IDENTITY_RESETS) to avoid public-id
71-
// duplicates. Synthesize a deterministic position-based id so they still
72-
// appear in the rebuilt TOC and round-trip via OOXML bookmarks.
69+
// Pasted/new paragraphs intentionally lose paraId/sdBlockId (see
70+
// InputRule.js SUPERDOC_SLICE_PASTE_IDENTITY_RESETS). Synthesize a
71+
// position-based id so they still appear in the rebuilt TOC.
7372
const sdBlockId =
7473
((attrs?.sdBlockId ?? attrs?.paraId) as string | undefined) ?? buildFallbackBlockNodeId('paragraph', pos);
75-
76-
// Update paragraph context for TC field collection
7774
currentParagraphSdBlockId = sdBlockId;
78-
7975
if (!sdBlockId) return true;
8076

8177
const text = flattenText(node);
82-
// Word's TOC field skips paragraphs that are styled as headings but
83-
// contain no visible text (page-break-only spacers, empty stubs).
84-
// Including them produces ghost entries that look like a regression.
85-
const hasVisibleText = text.trim().length > 0;
78+
// Word's TOC skips heading-styled paragraphs with no visible text
79+
// (page-break spacers, empty stubs).
80+
if (text.trim().length === 0) return true;
8681

87-
// Check heading by style (\o switch)
82+
// \o switch — heading-style level
8883
if (outlineLevels) {
8984
const headingLevel = getHeadingLevel(styleId);
90-
if (
91-
headingLevel != null &&
92-
headingLevel >= outlineLevels.from &&
93-
headingLevel <= outlineLevels.to &&
94-
hasVisibleText
95-
) {
85+
if (headingLevel != null && headingLevel >= outlineLevels.from && headingLevel <= outlineLevels.to) {
9686
sources.push({ text, level: headingLevel, sdBlockId, kind: 'heading' });
97-
// Continue descending to find TC fields within this paragraph
98-
return true;
87+
return true; // descend so TC fields inside this paragraph are still collected
9988
}
10089
}
10190

102-
// Check applied outline level (\u switch)
91+
// \u switch — applied paragraph outline level
10392
if (useApplied) {
10493
const effectiveLevels = outlineLevels ?? { from: 1, to: 9 };
10594
const rawOutlineLevel = paragraphProps?.outlineLevel as number | undefined;
10695
if (rawOutlineLevel != null) {
10796
const tocLevel = rawOutlineLevel + 1;
108-
if (tocLevel >= effectiveLevels.from && tocLevel <= effectiveLevels.to && hasVisibleText) {
97+
if (tocLevel >= effectiveLevels.from && tocLevel <= effectiveLevels.to) {
10998
sources.push({ text, level: tocLevel, sdBlockId, kind: 'appliedOutline' });
11099
return true;
111100
}
@@ -171,40 +160,38 @@ export interface EntryParagraphJson {
171160
content: Array<Record<string, unknown>>;
172161
}
173162

174-
/**
175-
* Builds ProseMirror-compatible paragraph JSON nodes for TOC entries.
176-
*
177-
* Each entry gets:
178-
* - Paragraph style: TOC{level}
179-
* - tocSourceId paragraph attribute (source heading/TC field's sdBlockId)
180-
* - Link mark with anchor pointing to a `_Toc`-prefixed bookmark name (when \h is set)
181-
* - Page number placeholder "0" with tocPageNumber mark
182-
* - Separator: custom (\p switch) or default tab
183-
*/
184-
/**
185-
* Optional context that lets the entry builder produce final-looking output
186-
* without a follow-up `mode: 'pageNumbers'` pass and without losing layout
187-
* particulars from the existing TOC.
188-
*/
189-
/** Mark JSON shape carried over from the existing TOC entry's text run. */
163+
/** A mark in JSON form, as carried on the rebuilt TOC entry's text runs. */
190164
export interface EntryTextMark {
191165
type: string;
192166
attrs?: Record<string, unknown>;
193167
}
194168

169+
/**
170+
* Optional context that lets the entry builder produce final-looking output
171+
* (resolved page numbers, preserved tab spacing, sampled font/size marks)
172+
* without a follow-up `mode: 'pageNumbers'` pass.
173+
*/
195174
export interface BuildTocEntryOptions {
196175
/** sdBlockId → page number map from PresentationEditor's last layout cycle. */
197176
pageMap?: Map<string, number>;
198177
/** Right-tab stop position (twips) to mirror the existing TOC's spacing. */
199178
tabPos?: number;
179+
/** Marks sampled from the existing TOC entry text. `link` is filtered out and rebuilt. */
180+
entryTextMarks?: EntryTextMark[];
200181
/**
201-
* Marks (font, size, textStyle, bold/italic, etc.) sampled from the existing
202-
* TOC entry's text run so a rebuild keeps the same visual styling. The link
203-
* mark is excluded — the builder rebuilds it from the source's bookmark name.
182+
* Paragraph-level `<w:rPr>` overrides sampled from the existing entry. Word
183+
* stamps these on TOC entries (e.g. `bold: false`, `italic: false`) to
184+
* disable the TOC1 paragraph style's `<w:b/><w:i/>`. Preserving them keeps
185+
* the rebuilt entries visually identical to the imported ones.
204186
*/
205-
entryTextMarks?: EntryTextMark[];
187+
paragraphRunProperties?: Record<string, unknown>;
206188
}
207189

190+
/**
191+
* Build TOC entry paragraphs. Each paragraph carries `pStyle="TOC{level}"`,
192+
* a `tocSourceId` attr pointing back to the source heading, and three runs:
193+
* the (linked) entry title, the tab/separator, and the page number.
194+
*/
208195
export function buildTocEntryParagraphs(
209196
sources: TocSource[],
210197
config: TocSwitchConfig,
@@ -224,82 +211,71 @@ const TAB_LEADER_MAP: Record<string, string> = {
224211
middleDot: 'middleDot',
225212
};
226213

214+
/** Wrap inline children in a `run` node — the schema unit that `wrapTextInRunsPlugin` skips. */
215+
function asRun(children: Array<Record<string, unknown>>): Record<string, unknown> {
216+
return { type: 'run', content: children };
217+
}
218+
227219
function buildEntryParagraph(
228220
source: TocSource,
229221
config: TocSwitchConfig,
230222
options: BuildTocEntryOptions = {},
231223
): EntryParagraphJson {
232224
const { display } = config;
233225

234-
// Entry text — preserves run formatting (font, size, bold, italic, textStyle…)
235-
// sampled from the existing TOC. Link mark is rebuilt from the source's
236-
// bookmark name and stacked on top of the preserved marks.
237-
//
238-
// We wrap the text in a `run` node because `wrapTextInRunsPlugin` would
239-
// otherwise wrap the bare paragraph-child text on appendTransaction and, for
240-
// the first child of a paragraph, *merge paragraph-style marks via addToSet*
241-
// — which clobbers our sampled `textStyle` (TNR/Hyperlink) with the TOC1
242-
// paragraph style's `textStyle` (Aptos). Pre-wrapping in a run keeps the
243-
// marks we constructed.
244-
const preservedMarks = (options.entryTextMarks ?? []).filter((mark) => mark?.type && mark.type !== 'link');
245-
const titleMarks: EntryTextMark[] = [...preservedMarks];
226+
// Title text. Marks are stacked: sampled (font/size/textStyle/bold/italic)
227+
// first, link last. Wrapped in a `run` so `wrapTextInRunsPlugin` does not
228+
// re-wrap and merge the TOC1 paragraph style's run properties via addToSet,
229+
// which would clobber the sampled `textStyle` mark.
230+
const titleMarks: EntryTextMark[] = (options.entryTextMarks ?? []).filter(
231+
(mark) => mark?.type && mark.type !== 'link',
232+
);
246233
if (display.hyperlinks) {
247234
titleMarks.push({
248235
type: 'link',
249-
attrs: {
250-
anchor: generateTocBookmarkName(source.sdBlockId),
251-
rId: null,
252-
history: true,
253-
},
236+
attrs: { anchor: generateTocBookmarkName(source.sdBlockId), rId: null, history: true },
254237
});
255238
}
256239
const titleText: Record<string, unknown> = { type: 'text', text: source.text || ' ' };
257240
if (titleMarks.length > 0) titleText.marks = titleMarks;
258241

259-
const content: Array<Record<string, unknown>> = [{ type: 'run', content: [titleText] }];
242+
const content: Array<Record<string, unknown>> = [asRun([titleText])];
260243

261-
// Determine whether to omit page number for this entry
244+
// Determine whether to omit page number for this entry.
262245
const omitRange = display.omitPageNumberLevels;
263-
const levelOmitted = omitRange && source.level >= omitRange.from && source.level <= omitRange.to;
264-
const entryOmitted = source.omitPageNumber;
265-
const omitPageNumber = levelOmitted || entryOmitted;
246+
const omitPageNumber = Boolean(
247+
(omitRange && source.level >= omitRange.from && source.level <= omitRange.to) || source.omitPageNumber,
248+
);
266249

267250
if (!omitPageNumber) {
268-
const separatorRunChildren: Array<Record<string, unknown>> = [];
269-
if (display.separator) {
270-
separatorRunChildren.push({ type: 'text', text: display.separator });
271-
} else {
272-
separatorRunChildren.push({ type: 'tab' });
273-
}
274-
content.push({ type: 'run', content: separatorRunChildren });
251+
// Separator: custom \p text or default tab.
252+
content.push(asRun([display.separator ? { type: 'text', text: display.separator } : { type: 'tab' }]));
275253

276-
// Page number — resolved from the page map when available so a single
277-
// mode 'all' rebuild produces final numbers; falls back to '0' placeholder
278-
// when the source paragraph is not yet in the page map (freshly pasted
279-
// headings whose synthetic id has not been seen by a layout cycle).
254+
// Page number — resolved from the page map when available; '0' placeholder
255+
// otherwise (e.g. freshly-pasted heading whose synthetic id hasn't been
256+
// seen by a layout cycle yet).
280257
const resolvedPage = options.pageMap?.get(source.sdBlockId);
281-
content.push({
282-
type: 'run',
283-
content: [
258+
content.push(
259+
asRun([
284260
{
285261
type: 'text',
286262
text: resolvedPage != null ? String(resolvedPage) : '0',
287263
marks: [{ type: 'tocPageNumber' }],
288264
},
289-
],
290-
});
265+
]),
266+
);
291267
}
292268

293-
// Build paragraph properties — add right-aligned tab stop when enabled
294-
const paragraphProperties: Record<string, unknown> = {
295-
styleId: `TOC${source.level}`,
296-
};
269+
const paragraphProperties: Record<string, unknown> = { styleId: `TOC${source.level}` };
270+
if (options.paragraphRunProperties && Object.keys(options.paragraphRunProperties).length > 0) {
271+
paragraphProperties.runProperties = { ...options.paragraphRunProperties };
272+
}
297273

298274
const rightAlign = display.rightAlignPageNumbers !== false; // default true
299275
if (rightAlign && !omitPageNumber) {
300276
// Word's default TOC tab leader is dots. The \p switch is only emitted
301-
// when a non-default separator is used, so an absent tabLeader means the
302-
// user expects dots, not "no leader". Honor an explicit 'none' to opt out.
277+
// for a non-default separator, so an absent `tabLeader` means "use the
278+
// default", not "no leader". `'none'` is the explicit opt-out.
303279
const leader =
304280
display.tabLeader === 'none' ? undefined : (display.tabLeader && TAB_LEADER_MAP[display.tabLeader]) || 'dot';
305281
const pos = options.tabPos ?? DEFAULT_RIGHT_TAB_POS;

0 commit comments

Comments
 (0)