@@ -112,6 +112,8 @@ import HelpModal from "./components/HelpModal.jsx";
112112
113113import { cacheGraphImages } from './swRegistration' ;
114114
115+ import { getNotesForTarget , hasContent } from './utils/notes.js' ;
116+
115117function uniqueSorted ( arr ) {
116118 return Array . from ( new Set ( arr ) ) . sort ( ) ;
117119}
@@ -593,9 +595,8 @@ function App() {
593595 } , [ clearVisitedForMap , mapName ] ) ;
594596
595597 // Typewriter control: opens viewer immediately but delays typewriter until zoom completes
596- const [ typewriterEnabled , setTypewriterEnabled ] = useState ( false ) ;
597- const [ shouldMountTypewriter , setShouldMountTypewriter ] = useState ( false ) ; // NEW: Controls if TypewriterText component should mount at all
598- const typewriterRef = useRef ( false ) ;
598+ // Store session data indexed by targetId to avoid race conditions with remounting
599+ const [ viewSessions , setViewSessions ] = useState ( { } ) ;
599600 // Track the latest zoom start to ignore stale completions
600601 const latestZoomTokenRef = useRef ( null ) ;
601602
@@ -610,7 +611,7 @@ function App() {
610611 ) ;
611612
612613 // ✅ ADD THIS RIGHT AFTER THE HOOK DECLARATIONS
613- console . log ( '🏁 [App] Component rendered with:' , { cdnBaseUrl, mapName, nodeCount : graphData . nodes . length , firstNodeImage : graphData . nodes [ 0 ] ?. imageUrl } ) ;
614+ //// console.log('🏁 [App] Component rendered with:', { cdnBaseUrl, mapName, nodeCount: graphData.nodes.length, firstNodeImage: graphData.nodes[0]?.imageUrl });
614615
615616 /** ---------- handlers ---------- **/
616617
@@ -660,109 +661,125 @@ useEffect(() => {
660661 const isTransitioningRef = useRef ( false ) ;
661662
662663 // Note viewing handlers - defined early to avoid circular dependencies
663- // Update handleStartNoteViewing to use the flag
664+ // Update handleStartNoteViewing to use the flag
664665 const handleStartNoteViewing = useCallback ( ( targetId , targetType ) => {
665- // Check if we're transitioning between nodes
666- const isTransitioning = noteViewingTarget && noteViewingTarget !== targetId ;
666+ // Check if we're switching between targets
667+ const switching = ! ! noteViewingTarget && noteViewingTarget !== targetId ;
667668
668- if ( isTransitioning ) {
669+ if ( switching ) {
670+ // Set switching flag to prevent camera zoom-out on close
671+ isSwitchingTargetsRef . current = true ;
669672 isTransitioningRef . current = true ;
670- // Clear the flag after the transition
673+ // Clear transition flag after a brief delay
671674 setTimeout ( ( ) => {
672675 isTransitioningRef . current = false ;
673676 } , 100 ) ;
674677 }
675678
676- // Treat clicking another node while already viewing as a "switch", not a close+open
677- const switching = ! ! noteViewingTarget && noteViewingTarget !== targetId ;
678- isSwitchingTargetsRef . current = switching ;
679- pendingViewTargetRef . current = targetId ;
680- // While opening/switching, suppress any empty-selection close from Cytoscape
681- suppressEmptyCloseRef . current = true ;
679+ // console.log('🎬 [handleStartNoteViewing] Starting:', {
680+ // targetId,
681+ // targetType,
682+ // switching,
683+ // previousTarget: noteViewingTarget,
684+ // timestamp: performance.now()
685+ // });
686+
687+ const unseenAtOpen = isUnseen ( targetId ) && hasContent ( graphData , targetId ) ;
688+ const sessionId = Symbol ( 'view-session' ) ;
689+
690+ // console.log('🔍 [handleStartNoteViewing] Unseen check:', {
691+ // targetId,
692+ // targetType,
693+ // isUnseen: isUnseen(targetId),
694+ // hasContent: hasContent(graphData, targetId),
695+ // unseenAtOpen,
696+ // ZOOM_TO_SELECTION
697+ // });
698+
699+ // Create session for this target
700+ setViewSessions ( prev => ( {
701+ ...prev ,
702+ [ targetId ] : {
703+ id : sessionId ,
704+ targetId,
705+ isUnseenAtOpen : unseenAtOpen ,
706+ typewriterReady : ! ZOOM_TO_SELECTION // if no zoom, we can typewriter immediately
707+ }
708+ } ) ) ;
682709
683- // ---- Compute first-open flag for typewriter logic ----
684- // Only typewriter the first time we open a given id (and only if there's content)
685- const hasContent =
686- ( Array . isArray ( graphData ?. notes ?. [ targetId ] ) ? graphData . notes [ targetId ] . join ( '\n\n' ) : ( graphData ?. notes ?. [ targetId ] || '' ) ) . trim ( ) . length > 0 ;
687- const unseenNow = isUnseen ( targetId ) ;
688- typewriterRef . current = Boolean ( unseenNow && hasContent ) ;
710+ // Open/switch the viewer
711+ dispatchAppState ( {
712+ type : ACTION_TYPES . START_NOTE_VIEWING ,
713+ payload : { targetId, targetType }
714+ } ) ;
689715
690- // Open the viewer immediately, but don't mount TypewriterText yet.
691- // We'll mount it after the zoom animation completes.
692- setTypewriterEnabled ( false ) ;
693- setShouldMountTypewriter ( false ) ; // Don't mount TypewriterText during animation
716+ // Mark visited immediately
717+ if ( targetType === 'node' ) markNodeVisited ( targetId ) ;
718+ else markEdgeVisited ( targetId ) ;
694719
695- // Zoom to selection if feature is enabled and we're in playing mode
696720 if ( ZOOM_TO_SELECTION && targetId ) {
697- printDebug ( "📹 [StartNoteViewing] Beginning zoom sequence for:" , targetId ) ;
721+ const token = sessionId ; // reuse the session id as token
698722
699- // Only save the "original camera" once, on the first zoom-in. For switches, reuse it.
723+ // Determine camera behavior based on switching state
700724 const shouldSaveCamera = ! hasOriginalCamera ( ) ;
701- const zoomStartTime = performance . now ( ) ;
702725
703- const zoomPromise = fitToSelection ( [ targetId ] , {
726+ fitToSelection ( [ targetId ] , {
704727 animate : true ,
705728 padding : 80 ,
706729 targetHalf : 'top' ,
707730 saveCamera : shouldSaveCamera ,
708731 zoomLevel : 'close'
709- } ) ;
710-
711- // Mount TypewriterText and enable it only when zoom completes AND we're still viewing this same target
712- const token = Symbol ( 'zoom-token' ) ;
713- latestZoomTokenRef . current = token ;
714- zoomPromise . finally ( ( ) => {
715- const zoomEndTime = performance . now ( ) ;
716- const totalZoomTime = zoomEndTime - zoomStartTime ;
717-
718- if ( latestZoomTokenRef . current === token ) {
719- printDebug ( "✅ [StartNoteViewing] Zoom complete, adding small delay before mounting typewriter:" , {
720- targetId,
721- totalTime : totalZoomTime . toFixed ( 2 ) + 'ms' ,
722- willTypewriter : typewriterRef . current
732+ } ) . finally ( ( ) => {
733+ // Only enable typewriter for the current live session
734+ setTimeout ( ( ) => {
735+ // console.log('⏰ [handleStartNoteViewing] Setting typewriterReady=true for session:', {
736+ // token: token.toString(),
737+ // targetId,
738+ // switching
739+ // });
740+ setViewSessions ( prev => {
741+ const session = prev [ targetId ] ;
742+ const shouldUpdate = session && session . id === token ;
743+ // console.log('📝 [setViewSessions] Update check:', {
744+ // shouldUpdate,
745+ // prevId: session?.id?.toString(),
746+ // token: token.toString(),
747+ // prevTargetId: session?.targetId,
748+ // targetId
749+ // });
750+ if ( ! shouldUpdate ) return prev ;
751+ return {
752+ ...prev ,
753+ [ targetId ] : { ...session , typewriterReady : true }
754+ } ;
723755 } ) ;
724756
725- // Add a small delay to ensure viewport streaming has fully resumed
726- // and any pending renders have completed before mounting TypewriterText
727- setTimeout ( ( ) => {
728- if ( latestZoomTokenRef . current === token ) {
729- printDebug ( "⌨️ [StartNoteViewing] Mounting and enabling typewriter now" ) ;
730- setShouldMountTypewriter ( true ) ; // Mount the component
731- setTypewriterEnabled ( true ) ; // Enable animation
732- } else {
733- printDebug ( "⚠️ [StartNoteViewing] Token changed during delay - NOT enabling typewriter" ) ;
734- }
735- } , 100 ) ; // 100ms grace period for rendering to settle
736- } else {
737- printDebug ( "⚠️ [StartNoteViewing] Zoom complete but token mismatch - NOT enabling typewriter" ) ;
738- }
757+ // Clear switching flag after zoom completes
758+ if ( switching ) {
759+ isSwitchingTargetsRef . current = false ;
760+ }
761+ } , 100 ) ; // tiny grace to let rendering settle (keep your 100ms)
739762 } ) ;
740763 } else {
741- printDebug ( "📹 [StartNoteViewing] No zoom needed, mounting and enabling typewriter immediately" ) ;
742- // No zoom → mount and enable typewriter immediately
743- setShouldMountTypewriter ( true ) ;
744- setTypewriterEnabled ( true ) ;
764+ // No zoom, clear switching flag immediately
765+ if ( switching ) {
766+ setTimeout ( ( ) => {
767+ isSwitchingTargetsRef . current = false ;
768+ } , 0 ) ;
769+ }
745770 }
746-
747- // Open (or switch) the viewer
748- dispatchAppState ( {
749- type : ACTION_TYPES . START_NOTE_VIEWING ,
750- payload : { targetId, targetType }
751- } ) ;
752-
753- // Mark as visited
754- if ( targetType === 'node' ) markNodeVisited ( targetId ) ;
755- else if ( targetType === 'edge' ) markEdgeVisited ( targetId ) ;
756-
757- // NOTE: we now clear the guards when the new selection actually lands
758- // (see handleNodeSelectionChange).
759- } , [ fitToSelection , noteViewingTarget , hasOriginalCamera , graphData , isUnseen , markNodeVisited , markEdgeVisited ] ) ;
771+ } , [ graphData , isUnseen , fitToSelection , hasOriginalCamera , markNodeVisited , markEdgeVisited , noteViewingTarget ] ) ;
760772
761773 // Update handleCloseNoteViewing to check the transition flag
762774 const handleCloseNoteViewing = useCallback ( ( ) => {
763- // Disable and unmount typewriter immediately on close
764- setTypewriterEnabled ( false ) ;
765- setShouldMountTypewriter ( false ) ; // Unmount TypewriterText component
775+ // Clear session for the closing target
776+ if ( noteViewingTarget ) {
777+ setViewSessions ( prev => {
778+ const { [ noteViewingTarget ] : _ , ...rest } = prev ;
779+ return rest ;
780+ } ) ;
781+ }
782+
766783 latestZoomTokenRef . current = null ; // invalidate any pending completion
767784
768785 // Prevent duplicate execution and transitions
@@ -800,7 +817,7 @@ useEffect(() => {
800817 pendingViewTargetRef . current = null ;
801818 isSwitchingTargetsRef . current = false ;
802819 }
803- } , [ mode , restoreOriginalCamera , hasOriginalCamera , clearCytoscapeSelections ] ) ;
820+ } , [ mode , restoreOriginalCamera , hasOriginalCamera , clearCytoscapeSelections , noteViewingTarget ] ) ;
804821
805822 // Debug modal open/close now via modalOps
806823
@@ -1392,12 +1409,12 @@ useEffect(() => {
13921409 />
13931410
13941411 < NoteViewerModal
1412+ key = { noteViewingTarget || 'no-target' }
13951413 targetId = { noteViewingTarget }
1396- notes = { noteViewingTarget ? ( graphData . notes ?. [ noteViewingTarget ] || [ ] ) : [ ] }
1414+ notes = { noteViewingTarget ? getNotesForTarget ( graphData , noteViewingTarget ) : [ ] }
13971415 onClose = { handleCloseNoteViewing }
1398- shouldTypewriter = { typewriterEnabled && typewriterRef . current }
1399- shouldShowText = { ! typewriterRef . current || typewriterEnabled }
1400- shouldMountTypewriter = { shouldMountTypewriter }
1416+ isUnseenAtOpen = { viewSessions [ noteViewingTarget ] ?. isUnseenAtOpen || false }
1417+ typewriterReady = { viewSessions [ noteViewingTarget ] ?. typewriterReady || false }
14011418 />
14021419
14031420 < HelpModal
0 commit comments