@@ -287,6 +287,36 @@ function parseText(text) {
287287 return { words, breaks } ;
288288}
289289
290+ // Word indices where sentences start (0 is always first sentence)
291+ function getSentenceStarts ( words ) {
292+ const starts = [ 0 ] ;
293+ for ( let i = 0 ; i < words . length - 1 ; i ++ ) {
294+ if ( / [ . ! ? ] [ " ' ) \] ] ? $ / . test ( words [ i ] ) ) starts . push ( i + 1 ) ;
295+ }
296+ return starts ;
297+ }
298+
299+ // Resolve URL hash params (pos=N or sentence=N) to a word index
300+ function resolveHashIndex ( hashStr , words ) {
301+ if ( ! hashStr ) return null ;
302+ const params = new URLSearchParams ( hashStr . replace ( / ^ # / , "" ) ) ;
303+ const sentenceParam = params . get ( "sentence" ) ;
304+ if ( sentenceParam != null ) {
305+ const n = parseInt ( sentenceParam , 10 ) ;
306+ if ( ! isNaN ( n ) && n >= 1 ) {
307+ const starts = getSentenceStarts ( words ) ;
308+ const idx = starts [ Math . min ( n - 1 , starts . length - 1 ) ] ;
309+ return idx ?? 0 ;
310+ }
311+ }
312+ const posParam = params . get ( "pos" ) ;
313+ if ( posParam != null ) {
314+ const n = parseInt ( posParam , 10 ) ;
315+ if ( ! isNaN ( n ) && n >= 0 ) return n ;
316+ }
317+ return null ;
318+ }
319+
290320// Spritz ORP algorithm - position where the eye naturally fixates
291321// Based on Optimal Viewing Position research (20-35% from left)
292322function getORPIndex ( wordLength ) {
@@ -548,14 +578,8 @@ function App() {
548578 setWords ( parsed . words ) ;
549579 setParagraphBreaks ( parsed . breaks ) ;
550580 // Restore position: URL hash (captured before effects) > IndexedDB > 0
551- let pos = 0 ;
552- if ( initialUrlHash ) {
553- const params = new URLSearchParams ( initialUrlHash . slice ( 1 ) ) ;
554- const urlPos = parseInt ( params . get ( "pos" ) , 10 ) ;
555- if ( ! isNaN ( urlPos ) && urlPos >= 0 ) pos = urlPos ;
556- } else if ( typeof savedPos === "number" ) {
557- pos = savedPos ;
558- }
581+ let pos = resolveHashIndex ( initialUrlHash , parsed . words ) ;
582+ if ( pos == null ) pos = typeof savedPos === "number" ? savedPos : 0 ;
559583 const clamped = Math . min ( Math . max ( 0 , pos ) , Math . max ( 0 , parsed . words . length - 1 ) ) ;
560584 _setCurrentIndex ( clamped ) ;
561585 }
@@ -728,6 +752,22 @@ function App() {
728752 }
729753 } , [ currentIndex , words . length , idbLoaded ] ) ;
730754
755+ // React to manual URL hash changes (jump by typing #pos=N or #sentence=N)
756+ useEffect ( ( ) => {
757+ if ( ! idbLoaded || words . length === 0 ) return ;
758+ const onHashChange = ( ) => {
759+ const target = resolveHashIndex ( window . location . hash , words ) ;
760+ if ( target == null ) return ;
761+ const clamped = Math . min ( Math . max ( 0 , target ) , words . length - 1 ) ;
762+ if ( clamped !== currentIndex ) {
763+ setIsPlaying ( false ) ;
764+ _setCurrentIndex ( clamped ) ;
765+ }
766+ } ;
767+ window . addEventListener ( "hashchange" , onHashChange ) ;
768+ return ( ) => window . removeEventListener ( "hashchange" , onHashChange ) ;
769+ } , [ idbLoaded , words , currentIndex ] ) ;
770+
731771 const getBaseDelay = useCallback ( ( ) => {
732772 return ( 60 / wpm ) * 1000 ;
733773 } , [ wpm ] ) ;
0 commit comments