Skip to content

Commit 99e562d

Browse files
committed
refactor(editor): decompose #rerender into named pipeline phases
Break the 299-line #rerender into 3 focused methods: - #convertDocToFlowBlocks: serialize doc, compute footnotes, build converter context, run toFlowBlocks, split decoration boundaries. Returns null on error (caller returns early). - #rerender: now 177 lines. Orchestrates the pipeline: convert → layout options → incremental layout → state update → paint. - #postPaint: DOM index rebuild, decorations, epoch sync, permission overlay, error reset, zoom, event emission, remote cursors. #rerender now reads as a clear 3-phase pipeline instead of a 299-line wall. Each phase can be understood independently.
1 parent 8c9602d commit 99e562d

1 file changed

Lines changed: 138 additions & 126 deletions

File tree

packages/super-editor/src/core/presentation-editor/PresentationEditor.ts

Lines changed: 138 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -2943,96 +2943,107 @@ export class PresentationEditor extends EventEmitter {
29432943
}
29442944
}
29452945

2946+
#convertDocToFlowBlocks(perfNow: () => number): {
2947+
docJson: unknown;
2948+
blocks: FlowBlock[];
2949+
bookmarks: Map<string, number>;
2950+
converterContext: ConverterContext | undefined;
2951+
sectionMetadata: SectionMetadata[];
2952+
layoutEpoch: number;
2953+
} | null {
2954+
let docJson;
2955+
try {
2956+
const start = perfNow();
2957+
docJson = this.#editor.getJSON();
2958+
perfLog(`[Perf] getJSON: ${(perfNow() - start).toFixed(2)}ms`);
2959+
} catch (error) {
2960+
this.#handleLayoutError('render', this.#decorateError(error, 'getJSON'));
2961+
return null;
2962+
}
2963+
2964+
const layoutEpoch = this.#epochMapper.getCurrentEpoch();
2965+
const sectionMetadata: SectionMetadata[] = [];
2966+
2967+
let blocks: FlowBlock[] | undefined;
2968+
let bookmarks: Map<string, number> = new Map();
2969+
let converterContext: ConverterContext | undefined;
2970+
try {
2971+
const converter = (this.#editor as Editor & { converter?: Record<string, unknown> }).converter;
2972+
const { footnoteNumberById, footnoteOrder } = computeFootnoteNumbering(this.#editor?.state?.doc);
2973+
const footnoteSignature = footnoteOrder.join('|');
2974+
if (footnoteSignature !== this.#footnoteNumberSignature) {
2975+
this.#flowBlockCache.clear();
2976+
this.#footnoteNumberSignature = footnoteSignature;
2977+
}
2978+
try {
2979+
if (converter && typeof converter === 'object') {
2980+
converter['footnoteNumberById'] = footnoteNumberById;
2981+
}
2982+
} catch {}
2983+
converterContext = buildConverterContext(converter, footnoteNumberById);
2984+
2985+
const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null);
2986+
const positionMap =
2987+
this.#editor?.state?.doc && docJson ? buildPositionMapFromPmDoc(this.#editor.state.doc, docJson) : null;
2988+
const commentsEnabled = this.#documentMode !== 'viewing' || this.#layoutOptions.enableCommentsInViewing === true;
2989+
2990+
const start = perfNow();
2991+
const result = toFlowBlocks(docJson, {
2992+
mediaFiles: (this.#editor?.storage?.image as { media?: Record<string, string> })?.media,
2993+
emitSectionBreaks: true,
2994+
sectionMetadata,
2995+
trackedChangesMode: this.#trackedChangesMode,
2996+
enableTrackedChanges: this.#trackedChangesEnabled,
2997+
enableComments: commentsEnabled,
2998+
enableRichHyperlinks: true,
2999+
themeColors: this.#editor?.converter?.themeColors ?? undefined,
3000+
converterContext,
3001+
flowBlockCache: this.#flowBlockCache,
3002+
...(positionMap ? { positions: positionMap } : {}),
3003+
...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}),
3004+
});
3005+
perfLog(`[Perf] toFlowBlocks: ${(perfNow() - start).toFixed(2)}ms (blocks=${result.blocks.length})`);
3006+
blocks = result.blocks;
3007+
bookmarks = result.bookmarks ?? new Map();
3008+
} catch (error) {
3009+
this.#handleLayoutError('render', this.#decorateError(error, 'toFlowBlocks'));
3010+
return null;
3011+
}
3012+
3013+
if (!blocks) {
3014+
this.#handleLayoutError('render', new Error('toFlowBlocks returned undefined blocks'));
3015+
return null;
3016+
}
3017+
3018+
// Split runs at decoration boundaries for highlight rendering
3019+
const state = this.#editor?.view?.state;
3020+
const decorationRanges = state ? this.#decorationBridge.collectDecorationRanges(state) : [];
3021+
if (decorationRanges.length > 0) {
3022+
blocks = splitRunsAtDecorationBoundaries(
3023+
blocks,
3024+
decorationRanges.map((r) => ({ from: r.from, to: r.to })),
3025+
);
3026+
}
3027+
3028+
this.#applyHtmlAnnotationMeasurements(blocks);
3029+
return { docJson, blocks, bookmarks, converterContext, sectionMetadata, layoutEpoch };
3030+
}
3031+
29463032
async #rerender() {
29473033
this.#selectionSync.onLayoutStart();
29483034
let layoutCompleted = false;
29493035

29503036
try {
2951-
let docJson;
29523037
const viewWindow = this.#visibleHost.ownerDocument?.defaultView ?? window;
29533038
const perf = viewWindow?.performance ?? GLOBAL_PERFORMANCE;
29543039
const perfNow = () => (perf?.now ? perf.now() : Date.now());
29553040
const startMark = perf?.now?.();
2956-
try {
2957-
const getJsonStart = perfNow();
2958-
docJson = this.#editor.getJSON();
2959-
const getJsonEnd = perfNow();
2960-
perfLog(`[Perf] getJSON: ${(getJsonEnd - getJsonStart).toFixed(2)}ms`);
2961-
} catch (error) {
2962-
this.#handleLayoutError('render', this.#decorateError(error, 'getJSON'));
2963-
return;
2964-
}
2965-
const layoutEpoch = this.#epochMapper.getCurrentEpoch();
29663041

2967-
const sectionMetadata: SectionMetadata[] = [];
2968-
let blocks: FlowBlock[] | undefined;
2969-
let bookmarks: Map<string, number> = new Map();
2970-
let converterContext: ConverterContext | undefined = undefined;
2971-
try {
2972-
const converter = (this.#editor as Editor & { converter?: Record<string, unknown> }).converter;
2973-
const { footnoteNumberById, footnoteOrder } = computeFootnoteNumbering(this.#editor?.state?.doc);
2974-
const footnoteSignature = footnoteOrder.join('|');
2975-
if (footnoteSignature !== this.#footnoteNumberSignature) {
2976-
this.#flowBlockCache.clear();
2977-
this.#footnoteNumberSignature = footnoteSignature;
2978-
}
2979-
try {
2980-
if (converter && typeof converter === 'object') {
2981-
converter['footnoteNumberById'] = footnoteNumberById;
2982-
}
2983-
} catch {}
2984-
converterContext = buildConverterContext(converter, footnoteNumberById);
2985-
const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null);
2986-
const positionMapStart = perfNow();
2987-
const positionMap =
2988-
this.#editor?.state?.doc && docJson ? buildPositionMapFromPmDoc(this.#editor.state.doc, docJson) : null;
2989-
const positionMapEnd = perfNow();
2990-
perfLog(`[Perf] buildPositionMapFromPmDoc: ${(positionMapEnd - positionMapStart).toFixed(2)}ms`);
2991-
const commentsEnabled =
2992-
this.#documentMode !== 'viewing' || this.#layoutOptions.enableCommentsInViewing === true;
2993-
const toFlowBlocksStart = perfNow();
2994-
const result = toFlowBlocks(docJson, {
2995-
mediaFiles: (this.#editor?.storage?.image as { media?: Record<string, string> })?.media,
2996-
emitSectionBreaks: true,
2997-
sectionMetadata,
2998-
trackedChangesMode: this.#trackedChangesMode,
2999-
enableTrackedChanges: this.#trackedChangesEnabled,
3000-
enableComments: commentsEnabled,
3001-
enableRichHyperlinks: true,
3002-
themeColors: this.#editor?.converter?.themeColors ?? undefined,
3003-
converterContext,
3004-
flowBlockCache: this.#flowBlockCache,
3005-
...(positionMap ? { positions: positionMap } : {}),
3006-
...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}),
3007-
});
3008-
const toFlowBlocksEnd = perfNow();
3009-
perfLog(
3010-
`[Perf] toFlowBlocks: ${(toFlowBlocksEnd - toFlowBlocksStart).toFixed(2)}ms (blocks=${result.blocks.length})`,
3011-
);
3012-
blocks = result.blocks;
3013-
bookmarks = result.bookmarks ?? new Map();
3014-
} catch (error) {
3015-
this.#handleLayoutError('render', this.#decorateError(error, 'toFlowBlocks'));
3016-
return;
3017-
}
3042+
// Phase 1: Serialize document + convert to FlowBlocks
3043+
const conversionResult = this.#convertDocToFlowBlocks(perfNow);
3044+
if (!conversionResult) return;
3045+
const { docJson, blocks, bookmarks, converterContext, sectionMetadata, layoutEpoch } = conversionResult;
30183046

3019-
if (!blocks) {
3020-
this.#handleLayoutError('render', new Error('toFlowBlocks returned undefined blocks'));
3021-
return;
3022-
}
3023-
3024-
// Split runs at decoration boundaries so bridge sync applies background only to the
3025-
// selected portion (like highlight mark) without adding a document mark.
3026-
const state = this.#editor?.view?.state;
3027-
const decorationRanges = state ? this.#decorationBridge.collectDecorationRanges(state) : [];
3028-
if (decorationRanges.length > 0) {
3029-
blocks = splitRunsAtDecorationBoundaries(
3030-
blocks,
3031-
decorationRanges.map((r) => ({ from: r.from, to: r.to })),
3032-
);
3033-
}
3034-
3035-
this.#applyHtmlAnnotationMeasurements(blocks);
30363047
const isSemanticFlow = this.#isSemanticFlowMode();
30373048

30383049
const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata);
@@ -3187,59 +3198,60 @@ export class PresentationEditor extends EventEmitter {
31873198
painter.paint(layout, this.#painterHost, mapping ?? undefined);
31883199
const painterPaintEnd = perfNow();
31893200
perfLog(`[Perf] painter.paint: ${(painterPaintEnd - painterPaintStart).toFixed(2)}ms`);
3190-
const painterPostStart = perfNow();
3191-
this.#applyVertAlignToLayout();
3192-
this.#rebuildDomPositionIndex();
3193-
this.#syncDecorations();
3194-
this.#domIndexObserverManager?.resume();
3195-
const painterPostEnd = perfNow();
3196-
perfLog(`[Perf] painter.postPaint: ${(painterPostEnd - painterPostStart).toFixed(2)}ms`);
3197-
this.#layoutEpoch = layoutEpoch;
3198-
if (this.#updateHtmlAnnotationMeasurements(layoutEpoch)) {
3199-
this.#pendingDocChange = true;
3200-
this.#scheduleRerender();
3201-
}
3202-
this.#epochMapper.onLayoutComplete(layoutEpoch);
3203-
this.#selectionSync.onLayoutComplete(layoutEpoch);
3201+
this.#postPaint(perfNow, layoutEpoch, blocksForLayout, measures, layout, perf, startMark);
32043202
layoutCompleted = true;
3205-
this.#updatePermissionOverlay();
3203+
} finally {
3204+
if (!layoutCompleted) {
3205+
this.#selectionSync.onLayoutAbort();
3206+
}
3207+
}
3208+
}
32063209

3207-
// Reset error state on successful layout
3208-
this.#layoutError = null;
3209-
this.#layoutErrorState = 'healthy';
3210-
this.#errorBanner.dismiss();
3210+
#postPaint(
3211+
perfNow: () => number,
3212+
layoutEpoch: number,
3213+
blocks: FlowBlock[],
3214+
measures: Measure[],
3215+
layout: Layout,
3216+
perf: Performance | undefined,
3217+
startMark: number | undefined,
3218+
) {
3219+
const postStart = perfNow();
3220+
this.#applyVertAlignToLayout();
3221+
this.#rebuildDomPositionIndex();
3222+
this.#syncDecorations();
3223+
this.#domIndexObserverManager?.resume();
3224+
perfLog(`[Perf] painter.postPaint: ${(perfNow() - postStart).toFixed(2)}ms`);
3225+
3226+
this.#layoutEpoch = layoutEpoch;
3227+
if (this.#updateHtmlAnnotationMeasurements(layoutEpoch)) {
3228+
this.#pendingDocChange = true;
3229+
this.#scheduleRerender();
3230+
}
3231+
this.#epochMapper.onLayoutComplete(layoutEpoch);
3232+
this.#selectionSync.onLayoutComplete(layoutEpoch);
3233+
this.#updatePermissionOverlay();
32113234

3212-
// Update viewport dimensions after layout (page count may have changed)
3213-
this.#applyZoom();
3235+
this.#layoutError = null;
3236+
this.#layoutErrorState = 'healthy';
3237+
this.#errorBanner.dismiss();
32143238

3215-
const metrics = createLayoutMetricsFromHelper(perf, startMark, layout, blocksForLayout);
3216-
const payload = { layout, blocks: blocksForLayout, measures, metrics };
3217-
this.emit('layoutUpdated', payload);
3218-
this.emit('paginationUpdate', payload);
3219-
3220-
// Emit fresh comment positions after layout completes.
3221-
// Always emit — even when empty — so the store can clear stale positions
3222-
// (e.g. when undo removes the last tracked-change mark).
3223-
const allowViewingCommentPositions = this.#layoutOptions.emitCommentPositionsInViewing === true;
3224-
if (this.#documentMode !== 'viewing' || allowViewingCommentPositions) {
3225-
const commentPositions = this.#collectCommentPositions();
3226-
this.emit('commentPositions', { positions: commentPositions });
3227-
}
3239+
this.#applyZoom();
32283240

3229-
this.#selectionSync.requestRender({ immediate: true });
3241+
const metrics = createLayoutMetricsFromHelper(perf, startMark, layout, blocks);
3242+
const payload = { layout, blocks, measures, metrics };
3243+
this.emit('layoutUpdated', payload);
3244+
this.emit('paginationUpdate', payload);
32303245

3231-
// Re-normalize remote cursor positions after layout completes.
3232-
// Local document changes shift absolute positions, so Yjs relative positions
3233-
// must be re-resolved against the updated editor state. Without this,
3234-
// remote cursors appear offset by the number of characters the local user typed.
3235-
if (this.#remoteCursorManager?.hasRemoteCursors()) {
3236-
this.#remoteCursorManager.markDirty();
3237-
this.#remoteCursorManager.scheduleUpdate();
3238-
}
3239-
} finally {
3240-
if (!layoutCompleted) {
3241-
this.#selectionSync.onLayoutAbort();
3242-
}
3246+
if (this.#documentMode !== 'viewing' || this.#layoutOptions.emitCommentPositionsInViewing === true) {
3247+
this.emit('commentPositions', { positions: this.#collectCommentPositions() });
3248+
}
3249+
3250+
this.#selectionSync.requestRender({ immediate: true });
3251+
3252+
if (this.#remoteCursorManager?.hasRemoteCursors()) {
3253+
this.#remoteCursorManager.markDirty();
3254+
this.#remoteCursorManager.scheduleUpdate();
32433255
}
32443256
}
32453257

0 commit comments

Comments
 (0)