@@ -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