Skip to content

Commit f793562

Browse files
committed
refactor(editor): decompose setupEditorListeners into named handlers
Replace 188 lines of inline closures with focused named methods: - #handleEditorUpdate: epoch tracking, cache invalidation, mapping - #handleEditorSelectionUpdate: selection sync, a11y announcement - #handleEditorTransaction: decoration bridge sync - #triggerRerender: common rerender trigger pattern (DRY) - #listenTo: DRY helper for event registration + cleanup tracking #setupEditorListeners is now a 25-line registration method that reads like a table of contents. Each handler has a descriptive name and a single responsibility. Reduces PresentationEditor from 4848 to 4757 lines (-91).
1 parent 330ff03 commit f793562

1 file changed

Lines changed: 82 additions & 172 deletions

File tree

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

Lines changed: 82 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -2334,193 +2334,103 @@ export class PresentationEditor extends EventEmitter {
23342334
});
23352335
}
23362336

2337-
#setupEditorListeners() {
2338-
const handleUpdate = ({ transaction }: { transaction?: Transaction }) => {
2339-
const trackedChangesChanged = this.#syncTrackedChangesPreferences();
2340-
if (transaction) {
2341-
this.#epochMapper.recordTransaction(transaction);
2342-
this.#selectionSync.setDocEpoch(this.#epochMapper.getCurrentEpoch());
2343-
2344-
// Detect Y.js-origin transactions (remote collaboration changes).
2345-
// These bypass the blockNodePlugin's sdBlockRev increment to prevent
2346-
// feedback loops, so the FlowBlockCache's fast revision comparison
2347-
// cannot be trusted — signal it to fall through to JSON comparison.
2348-
const ySyncMeta = transaction.getMeta?.(ySyncPluginKey);
2349-
if (ySyncMeta?.isChangeOrigin && transaction.docChanged) {
2350-
this.#flowBlockCache?.setHasExternalChanges(true);
2351-
}
2352-
// History undo/redo can restore prior paragraph content while preserving/reusing
2353-
// sdBlockRev values, which makes the cache's fast revision check unsafe.
2354-
// Force JSON comparison for this render cycle to avoid stale paragraph reuse.
2355-
const inputType = transaction.getMeta?.('inputType');
2356-
const isHistoryType = inputType === 'historyUndo' || inputType === 'historyRedo';
2357-
if (isHistoryType && transaction.docChanged) {
2358-
this.#flowBlockCache?.setHasExternalChanges(true);
2359-
}
2360-
}
2361-
if (trackedChangesChanged || transaction?.docChanged) {
2362-
this.#pendingDocChange = true;
2363-
// Store the mapping from this transaction for position updates during paint.
2364-
// Only stored for doc changes - other triggers don't have position shifts.
2365-
if (transaction?.docChanged) {
2366-
if (this.#pendingMapping !== null) {
2367-
// Multiple rapid transactions before rerender - compose the mappings.
2368-
// The painter's gate checks maps.length > 1 to trigger full rebuild,
2369-
// which is the safe fallback for complex/batched edits.
2370-
const combined = this.#pendingMapping.slice();
2371-
combined.appendMapping(transaction.mapping);
2372-
this.#pendingMapping = combined;
2373-
} else {
2374-
this.#pendingMapping = transaction.mapping;
2375-
}
2376-
}
2377-
this.#selectionSync.onLayoutStart();
2378-
this.#scheduleRerender();
2379-
}
2380-
// Update local cursor in awareness whenever document changes
2381-
// This ensures cursor position is broadcast with each keystroke
2382-
if (transaction?.docChanged) {
2383-
this.#updateLocalAwarenessCursor();
2384-
// Clear cell anchor on document changes to prevent stale references
2385-
// (table structure may have changed, cell positions may be invalid)
2386-
this.#editorInputManager?.clearCellAnchor();
2387-
}
2388-
};
2389-
const handleSelection = () => {
2390-
// Use immediate rendering for selection-only changes (clicks, arrow keys).
2391-
// Without immediate, the render is RAF-deferred — leaving a window where
2392-
// a remote collaborator's edit can cancel the pending render via
2393-
// setDocEpoch → cancelScheduledRender. Immediate rendering is safe here:
2394-
// if layout is updating (due to a concurrent doc change), flushNow()
2395-
// is a no-op and the render will be picked up after layout completes.
2396-
this.#scheduleSelectionUpdate({ immediate: true });
2397-
// Update local cursor in awareness for collaboration
2398-
// This bypasses y-prosemirror's focus check which may fail for hidden PM views
2399-
this.#updateLocalAwarenessCursor();
2400-
this.#scheduleA11ySelectionAnnouncement();
2401-
};
2402-
2403-
// The 'transaction' event fires for ALL transactions (doc changes,
2404-
// selection changes, meta-only). The 'update' event only fires for
2405-
// docChanged transactions, and 'selectionUpdate' only for selection
2406-
// changes. A meta-only transaction (e.g., a custom command that sets
2407-
// plugin state without editing text) fires neither.
2408-
//
2409-
// We listen on 'transaction' so the decoration bridge picks up changes
2410-
// from any transaction type. The bridge's own identity check + RAF
2411-
// coalescing prevent unnecessary work.
2412-
// When decoration state changes without a doc change (e.g. setFocus), we must
2413-
// still run a full rerender so runs are split at the new decoration boundaries;
2414-
// otherwise the bridge applies the class to whole runs and highlights too much.
2415-
const handleTransaction = (event?: { transaction?: Transaction }) => {
2416-
const tr = event?.transaction;
2417-
this.#decorationBridge.recordTransaction(tr);
2418-
const state = this.#editor?.view?.state;
2419-
const decorationChanged = state && this.#decorationBridge.hasChanges(state);
2420-
// Sync immediately whenever decorations changed so e.g. clearFocus removes
2421-
// highlight-selection in the same tick. Only restore when we had a doc change.
2422-
if (decorationChanged) {
2423-
const restoreEmpty = tr ? tr.docChanged === true : false;
2424-
this.#decorationBridge.sync(state!, this.#domPositionIndex, {
2425-
restoreEmptyDecorations: restoreEmpty,
2426-
});
2427-
} else {
2428-
// No immediate sync; schedule coalesced sync on next frame.
2429-
this.#scheduleDecorationSync();
2430-
}
2431-
if (decorationChanged) {
2432-
this.#pendingDocChange = true;
2433-
this.#selectionSync.onLayoutStart();
2434-
this.#scheduleRerender();
2435-
}
2436-
};
2437-
2438-
this.#editor.on('update', handleUpdate);
2439-
this.#editor.on('selectionUpdate', handleSelection);
2440-
this.#editor.on('transaction', handleTransaction);
2441-
this.#editorListeners.push({ event: 'update', handler: handleUpdate as (...args: unknown[]) => void });
2442-
this.#editorListeners.push({ event: 'selectionUpdate', handler: handleSelection as (...args: unknown[]) => void });
2443-
this.#editorListeners.push({ event: 'transaction', handler: handleTransaction as (...args: unknown[]) => void });
2444-
2445-
// Listen for page style changes (e.g., margin adjustments via ruler).
2446-
// These changes don't modify document content (docChanged === false),
2447-
// so the 'update' event isn't emitted. The dedicated pageStyleUpdate event
2448-
// provides clearer semantics and better debugging than checking transaction meta flags.
2449-
const handlePageStyleUpdate = () => {
2450-
this.#pendingDocChange = true;
2451-
this.#selectionSync.onLayoutStart();
2452-
this.#scheduleRerender();
2453-
};
2454-
this.#editor.on('pageStyleUpdate', handlePageStyleUpdate);
2455-
this.#editorListeners.push({
2456-
event: 'pageStyleUpdate',
2457-
handler: handlePageStyleUpdate as (...args: unknown[]) => void,
2458-
});
2337+
#listenTo(event: string, handler: (...args: any[]) => void) {
2338+
this.#editor.on(event, handler);
2339+
this.#editorListeners.push({ event, handler });
2340+
}
24592341

2460-
// Listen for stylesheet default changes (e.g., styles.apply mutations to docDefaults).
2461-
// These changes mutate translatedLinkedStyles directly and need a full re-render
2462-
// so the style-engine picks up the updated default properties.
2463-
const handleStylesDefaultsChanged = () => {
2342+
#setupEditorListeners() {
2343+
this.#listenTo('update', (e: { transaction?: Transaction }) => this.#handleEditorUpdate(e.transaction));
2344+
this.#listenTo('selectionUpdate', () => this.#handleEditorSelectionUpdate());
2345+
this.#listenTo('transaction', (e?: { transaction?: Transaction }) => this.#handleEditorTransaction(e?.transaction));
2346+
this.#listenTo('pageStyleUpdate', () => this.#triggerRerender());
2347+
this.#listenTo('stylesDefaultsChanged', () => {
24642348
this.#pendingDocChange = true;
24652349
this.#scheduleRerender();
2466-
};
2467-
this.#editor.on('stylesDefaultsChanged', handleStylesDefaultsChanged);
2468-
this.#editorListeners.push({
2469-
event: 'stylesDefaultsChanged',
2470-
handler: handleStylesDefaultsChanged as (...args: unknown[]) => void,
24712350
});
2472-
2473-
const handleCollaborationReady = (payload: unknown) => {
2351+
this.#listenTo('collaborationReady', (payload: unknown) => {
24742352
this.emit('collaborationReady', payload);
2475-
// Setup remote cursor rendering after collaboration is ready
2476-
// Only setup if presence is enabled in layout options
24772353
if (this.#options.collaborationProvider?.awareness && this.#layoutOptions.presence?.enabled !== false) {
24782354
this.#setupCollaborationCursors();
24792355
}
2480-
};
2481-
this.#editor.on('collaborationReady', handleCollaborationReady);
2482-
this.#editorListeners.push({
2483-
event: 'collaborationReady',
2484-
handler: handleCollaborationReady as (...args: unknown[]) => void,
24852356
});
2486-
2487-
// Handle remote header/footer changes from collaborators
2488-
const handleRemoteHeaderFooterChanged = (payload: {
2489-
type: 'header' | 'footer';
2490-
sectionId: string;
2491-
content: unknown;
2492-
}) => {
2357+
this.#listenTo('remoteHeaderFooterChanged', (payload: { sectionId: string }) => {
24932358
this.#headerFooterSession?.adapter?.invalidate(payload.sectionId);
24942359
this.#headerFooterSession?.manager?.refresh();
2495-
this.#pendingDocChange = true;
2496-
this.#scheduleRerender();
2497-
};
2498-
this.#editor.on('remoteHeaderFooterChanged', handleRemoteHeaderFooterChanged);
2499-
this.#editorListeners.push({
2500-
event: 'remoteHeaderFooterChanged',
2501-
handler: handleRemoteHeaderFooterChanged as (...args: unknown[]) => void,
2360+
this.#triggerRerender();
25022361
});
2362+
this.#listenTo('commentsUpdate', (payload: { activeCommentId?: string | null }) => {
2363+
if (this.#domPainter?.setActiveComment && 'activeCommentId' in payload) {
2364+
this.#domPainter.setActiveComment(payload.activeCommentId ?? null);
2365+
this.#triggerRerender();
2366+
}
2367+
});
2368+
}
25032369

2504-
// Listen for comment selection changes to update Layout Engine highlighting
2505-
const handleCommentsUpdate = (payload: { activeCommentId?: string | null }) => {
2506-
if (this.#domPainter?.setActiveComment) {
2507-
// Only update active comment when the field is explicitly present in the payload.
2508-
// This prevents unrelated events (like tracked change updates) from clearing
2509-
// the active comment selection unexpectedly.
2510-
if ('activeCommentId' in payload) {
2511-
const activeId = payload.activeCommentId ?? null;
2512-
this.#domPainter.setActiveComment(activeId);
2513-
// Mark as needing re-render to apply the new active comment highlighting
2514-
this.#pendingDocChange = true;
2515-
this.#scheduleRerender();
2370+
#triggerRerender() {
2371+
this.#pendingDocChange = true;
2372+
this.#selectionSync.onLayoutStart();
2373+
this.#scheduleRerender();
2374+
}
2375+
2376+
#handleEditorUpdate(transaction?: Transaction) {
2377+
const trackedChangesChanged = this.#syncTrackedChangesPreferences();
2378+
if (transaction) {
2379+
this.#epochMapper.recordTransaction(transaction);
2380+
this.#selectionSync.setDocEpoch(this.#epochMapper.getCurrentEpoch());
2381+
2382+
// Y.js-origin or history undo/redo transactions may reuse sdBlockRev values,
2383+
// making the FlowBlockCache's fast revision check unsafe. Force JSON comparison.
2384+
const ySyncMeta = transaction.getMeta?.(ySyncPluginKey);
2385+
const inputType = transaction.getMeta?.('inputType');
2386+
const needsFullComparison =
2387+
(ySyncMeta?.isChangeOrigin && transaction.docChanged) ||
2388+
((inputType === 'historyUndo' || inputType === 'historyRedo') && transaction.docChanged);
2389+
if (needsFullComparison) {
2390+
this.#flowBlockCache?.setHasExternalChanges(true);
2391+
}
2392+
}
2393+
2394+
if (trackedChangesChanged || transaction?.docChanged) {
2395+
this.#pendingDocChange = true;
2396+
if (transaction?.docChanged) {
2397+
if (this.#pendingMapping !== null) {
2398+
const combined = this.#pendingMapping.slice();
2399+
combined.appendMapping(transaction.mapping);
2400+
this.#pendingMapping = combined;
2401+
} else {
2402+
this.#pendingMapping = transaction.mapping;
25162403
}
25172404
}
2518-
};
2519-
this.#editor.on('commentsUpdate', handleCommentsUpdate);
2520-
this.#editorListeners.push({
2521-
event: 'commentsUpdate',
2522-
handler: handleCommentsUpdate as (...args: unknown[]) => void,
2523-
});
2405+
this.#selectionSync.onLayoutStart();
2406+
this.#scheduleRerender();
2407+
}
2408+
2409+
if (transaction?.docChanged) {
2410+
this.#updateLocalAwarenessCursor();
2411+
this.#editorInputManager?.clearCellAnchor();
2412+
}
2413+
}
2414+
2415+
#handleEditorSelectionUpdate() {
2416+
this.#scheduleSelectionUpdate({ immediate: true });
2417+
this.#updateLocalAwarenessCursor();
2418+
this.#scheduleA11ySelectionAnnouncement();
2419+
}
2420+
2421+
#handleEditorTransaction(tr?: Transaction) {
2422+
this.#decorationBridge.recordTransaction(tr);
2423+
const state = this.#editor?.view?.state;
2424+
const decorationChanged = state && this.#decorationBridge.hasChanges(state);
2425+
2426+
if (decorationChanged) {
2427+
this.#decorationBridge.sync(state!, this.#domPositionIndex, {
2428+
restoreEmptyDecorations: tr ? tr.docChanged === true : false,
2429+
});
2430+
this.#triggerRerender();
2431+
} else {
2432+
this.#scheduleDecorationSync();
2433+
}
25242434
}
25252435

25262436
/**

0 commit comments

Comments
 (0)