import { action , observable , reaction } from "mobx" ; import { ITokenizedTextContent , TextTokenType } from "~/app/model" ; import { appendElement , getElementNo , prependElement } from "~/util/DomUtils" ; import { TextSelection } from "~/reader/TextSelection" ; export class TextView { private static readonly debug = false ; private static readonly translationAttributeName = "data-translation" ; private static readonly textTransitionTimeMs = 200 ; @ observable public firstPageElementNo : number = 0 ; @ observable public lastPageElementNo : number = 0 ; @ observable private _range ? : [ number , number ] ; private selection ? : TextSelection ; private mouse1Down : boolean = false ; private rootElementId ! : string ; private root ! : HTMLElement ; public constructor ( private onSelect , private onRangeChange ) { reaction ( ( ) => this . _range , range => range && this . onRangeChange ( range ) ) ; } public attach ( rootElementId ) { this . rootElementId = rootElementId ; const root = document . getElementById ( rootElementId ) ; if ( ! root ) { return ; } this . root = root ; this . root . parentElement ! . addEventListener ( "mousedown" , this . handleBodyMouseDown ) ; this . root . parentElement ! . addEventListener ( "mouseup" , this . handleBodyMouseUp ) ; this . handleWindowResize ( ) ; window . addEventListener ( "resize" , ( ) => { this . handleWindowResize ( ) ; } ) ; } public showTranslation ( translation ) { this . selection ! . firstElement . setAttribute ( TextView . translationAttributeName , translation ) ; } public get firstVisibleElement ( ) : Element | undefined { if ( ! this . root ) { return undefined ; } const firstParagraph = this . root . firstElementChild ; return ( firstParagraph && firstParagraph . firstElementChild ) || undefined ; } public get lastVisibleElement ( ) : Element | undefined { if ( ! this . root ) { return undefined ; } const rootBoundaryRight = this . root . offsetLeft + this . root . offsetWidth ; let previousElement ; for ( let i = 0 ; i < this . root . children . length ; i ++ ) { const paragraph = this . root . children [ i ] ; for ( let j = 0 ; j < paragraph . children . length ; j ++ ) { const currentElement = paragraph . children [ j ] as HTMLElement ; if ( currentElement . offsetLeft > rootBoundaryRight ) { return previousElement ; } previousElement = currentElement ; } } return previousElement ; } private get liveFirstVisibleElementNo ( ) : number | undefined { return getElementNo ( this . firstVisibleElement ) ; } private get liveLastVisibleElementNo ( ) : number | undefined { return getElementNo ( this . lastVisibleElement ) ; } public renderPage ( content ) { this . fadeOutRoot ( ) ; const startTime = window . performance . now ( ) ; const newRoot = this . createNewRoot ( ) ; const { types , values , startNo } = content ; let paragraph ; for ( let i = 0 ; i < types . length ; i ++ ) { if ( ! paragraph ) { paragraph = document . createElement ( "p" ) ; appendElement ( newRoot , paragraph ) ; } if ( types [ i ] === TextTokenType . ParagraphBreak && paragraph . children . length > 0 ) { paragraph = undefined ; continue ; } const el = this . createTextElement ( types [ i ] , values [ i ] , i + startNo , TextView . debug ) ; appendElement ( paragraph , el ) ; } if ( paragraph ) { appendElement ( newRoot , paragraph ) ; } return new Promise ( ( resolve , _ ) => { setTimeout ( ( ) => { this . replaceRoot ( newRoot ) ; this . updateRange ( ) ; this . fadeInRoot ( ) ; resolve ( ) ; } , TextView . textTransitionTimeMs + window . performance . now ( ) - startTime ) ; } ) ; } public renderPrevPage ( content ) { this . fadeOutRoot ( ) ; return new Promise ( ( resolve , _ ) => { setTimeout ( ( ) => { this . doRenderPrevPage ( content ) ; this . updateRange ( ) ; this . fadeInRoot ( ) ; resolve ( ) ; } , TextView . textTransitionTimeMs ) ; } ) ; } private doRenderPrevPage ( content ) { this . replaceRoot ( this . root . cloneNode ( false ) ) ; const { types , values , startNo } = content ; const rootBoundaryRight = this . root . offsetLeft + this . root . offsetWidth ; this . root . innerHTML = "" ; let paragraph ; let targetLastElement ; for ( let i = types . length - 1 ; i >= 0 ; i -- ) { if ( types [ i ] === TextTokenType . ParagraphBreak ) { paragraph = undefined ; continue ; } if ( ! paragraph ) { paragraph = document . createElement ( "p" ) ; prependElement ( this . root , paragraph ) ; } const elNo = i + startNo ; const el = this . createTextElement ( types [ i ] , values [ i ] , elNo , TextView . debug ) ; if ( ! targetLastElement ) { targetLastElement = el as HTMLElement ; } prependElement ( paragraph , el ) ; if ( targetLastElement . offsetLeft > rootBoundaryRight ) { this . removeFirstTextElement ( ) ; break ; } } if ( paragraph && paragraph . children . length === 0 ) { this . root . removeChild ( paragraph ) ; } } private replaceRoot ( newRoot ) { this . root ! . parentNode ! . replaceChild ( newRoot , this . root ) ; this . root = newRoot as HTMLElement ; } private fadeOutRoot ( ) { this . root . style . opacity = "0" ; } private fadeInRoot ( ) { this . root . style . opacity = "1" ; } private createNewRoot ( ) { const newRoot = document . createElement ( "div" ) ; newRoot . id = this . rootElementId ; newRoot . style . opacity = "0" ; newRoot . style . transitionProperty = "opacity" ; newRoot . style . transitionTimingFunction = "ease-in" ; return newRoot ; } private removeFirstTextElement ( ) { const firstParagraph = this . root . firstChild as HTMLElement ; const numFirstParagraphChildren = firstParagraph . children . length ; if ( numFirstParagraphChildren === 0 ) { this . root . removeChild ( firstParagraph ) ; } else { firstParagraph . removeChild ( firstParagraph . firstChild ! ) ; } } private createTextElement ( type , value , no , debug = false ) { const el = document . createElement ( "span" ) ; if ( type === TextTokenType . Word ) { el . addEventListener ( "mouseenter" , this . handleWordMouseEnter ) ; el . addEventListener ( "mousedown" , this . handleWordMouseDown ) ; } el . dataset . no = String ( no ) ; el . textContent = debug ? `${ el . dataset . no } ` : value ; return el ; } private handleWindowResize ( ) { this . updateRange ( ) ; } @ action private updateRange ( ) { if ( ! this . firstVisibleElement || ! this . lastVisibleElement ) { this . _range = undefined ; return ; } this . firstPageElementNo = this . liveFirstVisibleElementNo ! ; this . lastPageElementNo = this . liveLastVisibleElementNo ! ; const maybeNewRange = [ this . firstPageElementNo ! , this . lastPageElementNo ! ] ; if ( ! this . _range || maybeNewRange [ 0 ] !== this . _range [ 0 ] || maybeNewRange [ 1 ] !== this . _range [ 1 ] ) { this . _range = maybeNewRange ; } } private clearSelection ( ) { if ( ! this . selection ) { return ; } this . selection . firstElement . removeAttribute ( TextView . translationAttributeName ) ; this . selection . clear ( ) ; this . selection = undefined ; this . onSelect ( "" ) ; } private beginSelection ( el ) { if ( this . selection ) { this . clearSelection ( ) ; } this . selection = new TextSelection ( ) ; this . selection . update ( el ) ; } private handleBodyMouseDown = ( e ) => { if ( this . selection ) { this . clearSelection ( ) ; } if ( e . button === 0 ) { this . mouse1Down = true ; } } ; private handleBodyMouseUp = ( e ) => { if ( e . button !== 0 ) { return ; } if ( this . selection ) { this . onSelect ( this . selection . text ) ; } this . mouse1Down = false ; } ; private handleWordMouseEnter = ( e ) => { if ( this . mouse1Down && this . selection ) { this . selection . update ( e . toElement ) ; } } ; private handleWordMouseDown = ( e ) => { if ( e . button !== 0 ) { return ; } if ( this . selection ) { this . clearSelection ( ) ; } this . beginSelection ( e . toElement ) ; this . mouse1Down = true ; e . stopPropagation ( ) ; } ; }