import * as m from 'mithril' ; import documentState from '../state/documentState' ; import uiState from '../state/uiState' ; interface Attrs { documentDimensions : { height : number ; width : number ; } ; performReset : boolean ; } returns function DisplayedDocument ( ) { interface ClickableCircle { id : number ; x : number ; y : number ; radius : number ; } interface ClickableRectangle { id : number ; x : number ; y : number ; width : number ; height : number ; } interface Coordinates { x : number ; y : number ; } interface NodeDimensionInfo { bubbleHeight : number ; bubbleWidth : number ; heightWithChildren : number ; } type AllDimensions = Map < number , NodeDimensionInfo > ; const CHILD_PADDING = { x : 30 , y : 15 , } ; const FOLDING_ICON_RADIUS = 10 ; const NODE_PADDING = { x : 5 , y : 5 , } ; const NODE_LINES_VERTICAL_PADDING = 4 ; const devicePixelRatio = window . devicePixelRatio || 1 ; let fontSize = uiState . getCurrentFontSize ( ) ; let canvasElement ; let ctx ; const currentDocDimensions = { width : - 1 , height : - 1 , } ; let clickableFoldingIcons = [ ] ; let clickableNodes = [ ] ; let dragging = false ; let previousPointerCoords = { x : 0 , y : 0 , } ; const cumulativeCanvasTranslation = { x : 0 , y : 0 , } ; param allDimensions param nodeId function calculateDimensions ( allDimensions , nodeId ) { const linesForThisNode = getNodeLines ( documentState . getNodeContents ( nodeId ) ) ; const maxLineWidth = linesForThisNode . reduce ( ( acc , line ) => { const textMetrics = ctx . measureText ( line ) ; return textMetrics . width > acc ? textMetrics . width : acc ; } , 0 ) ; const bubbleDim = { h : ( fontSize * linesForThisNode . length ) + NODE_LINES_VERTICAL_PADDING * ( linesForThisNode . length - 1 ) + 2 * NODE_PADDING . y , w : maxLineWidth + 2 * NODE_PADDING . x , } ; let totalChildrenHeight = 0 ; if ( documentState . getChildrenVisible ( nodeId ) ) { const childIds = documentState . getNodeChildIds ( nodeId ) ; childIds . forEach ( ( childId ) => { calculateDimensions ( allDimensions , childId ) ; totalChildrenHeight += safeGetDimensions ( allDimensions , childId ) . heightWithChildren ; } ) ; if ( childIds . length > 0 ) { totalChildrenHeight += ( childIds . length - 1 ) * CHILD_PADDING . y ; } } allDimensions . set ( nodeId , { bubbleHeight : bubbleDim . h , bubbleWidth : bubbleDim . w , heightWithChildren : Math . max ( bubbleDim . h , totalChildrenHeight ) , } ) ; } /**
     * Stop dragging of user document
     */ function dragEnd ( ) { dragging = false ; } param x param y function dragMove ( x , y ) { if ( ! dragging ) return ; const deltaX = x - previousPointerCoords . x ; const deltaY = y - previousPointerCoords . y ; previousPointerCoords = { x , y } ; cumulativeCanvasTranslation . x += deltaX ; cumulativeCanvasTranslation . y += deltaY ; ctx . translate ( deltaX , deltaY ) ; } param x param y function dragStart ( x , y ) { dragging = true ; previousPointerCoords = { x , y } ; } param contents returns function getNodeLines ( contents ) { const lines = [ ] ; let remainingContents = contents . slice ( ) ; while ( remainingContents !== '' ) { let lastChar = remainingContents . length - 1 ; let requiredWidth = ctx . measureText ( remainingContents . substring ( 0 , lastChar + 1 ) ) . width ; while ( requiredWidth > 0.75 * currentDocDimensions . width && lastChar > 0 ) { lastChar -= 1 ; while ( remainingContents . charAt ( lastChar ) !== ' ' && lastChar > 0 ) { lastChar -= 1 ; } requiredWidth = ctx . measureText ( remainingContents . substring ( 0 , lastChar + 1 ) ) . width ; } lines . push ( remainingContents . substring ( 0 , lastChar + 1 ) ) ; remainingContents = remainingContents . slice ( lastChar + 1 ) ; } return lines ; } param allDimensions param parentDims param childIds returns function getNodesRelativeYCoordinates ( allDimensions , parentDims , childIds ) { const yCoords = [ ] ; let nextChildBoxY = - 1 * parentDims . heightWithChildren / 2 ; childIds . forEach ( ( childId ) => { const thisChildDims = safeGetDimensions ( allDimensions , childId ) ; const thisChildBoxCenter = nextChildBoxY + thisChildDims . heightWithChildren / 2 ; yCoords . push ( { bubbleTop : thisChildBoxCenter - thisChildDims . bubbleHeight / 2 , bubbleCenter : thisChildBoxCenter , } ) ; nextChildBoxY += thisChildDims . heightWithChildren + CHILD_PADDING . y ; } ) ; return yCoords ; } param e function onClick ( e ) { const canvasX = e . pageX - canvasElement . offsetLeft - cumulativeCanvasTranslation . x ; const canvasY = e . pageY - canvasElement . offsetTop - cumulativeCanvasTranslation . y ; clickableNodes . forEach ( ( region ) => { if ( ( canvasX >= region . x && canvasX <= region . x + region . width ) && ( canvasY >= region . y && canvasY <= region . y + region . height ) ) { documentState . setSelectedNodeId ( region . id ) ; } } ) ; clickableFoldingIcons . forEach ( ( region ) => { const distanceToCenter = Math . sqrt ( ( canvasX - region . x ) ** 2 + ( canvasY - region . y ) ** 2 ) ; if ( distanceToCenter <= region . radius ) { documentState . toggleChildrenVisibility ( region . id ) ; } } ) ; } param e function onMouseDown ( e ) { dragStart ( e . pageX , e . pageY ) ; } param e function onMouseMove ( e ) { dragMove ( e . pageX , e . pageY ) ; } /**
     * Handle mouse out events -- stop dragging so there's no weird dragging
     * behavior when mouse re-enters the canvas
     */ function onMouseOut ( ) { dragEnd ( ) ; } /**
     * Handle mouse up events by turning off dragging
     */ function onMouseUp ( ) { dragEnd ( ) ; } /**
     * Handle touch end events
     */ function onTouchEnd ( ) { dragEnd ( ) ; } param e function onTouchMove ( e ) { dragMove ( e . touches [ 0 ] . pageX , e . touches [ 0 ] . pageY ) ; } param e function onTouchStart ( e ) { dragStart ( e . touches [ 0 ] . pageX , e . touches [ 0 ] . pageY ) ; } param curveStart param curveEnd function renderChildConnector ( curveStart , curveEnd ) { ctx . beginPath ( ) ; ctx . moveTo ( curveStart . x , curveStart . y ) ; ctx . bezierCurveTo ( curveEnd . x , curveStart . y , curveStart . x , curveEnd . y , curveEnd . x , curveEnd . y ) ; ctx . stroke ( ) ; } param allDimensions param nodeId param bubblePos function renderNodesRecursively ( allDimensions , nodeId , bubblePos ) { const myDims = safeGetDimensions ( allDimensions , nodeId ) ; const childrenVisible = documentState . getChildrenVisible ( nodeId ) ; renderNodeContentsAndBubble ( myDims , bubblePos , nodeId ) ; const childIds = documentState . getNodeChildIds ( nodeId ) ; if ( childIds . length > 0 ) { const foldingIconPos = { x : bubblePos . x + myDims . bubbleWidth + FOLDING_ICON_RADIUS , y : bubblePos . y + myDims . bubbleHeight / 2 , } ; ctx . beginPath ( ) ; ctx . arc ( foldingIconPos . x , foldingIconPos . y , FOLDING_ICON_RADIUS , 0 , 2 * Math . PI ) ; if ( childrenVisible ) { ctx . stroke ( ) ; } else { ctx . fill ( ) ; } clickableFoldingIcons . push ( { id : nodeId , x : foldingIconPos . x , y : foldingIconPos . y , radius : FOLDING_ICON_RADIUS , } ) ; if ( childrenVisible ) { const childrenX = foldingIconPos . x + FOLDING_ICON_RADIUS + CHILD_PADDING . x ; const relativeChildYCoords = getNodesRelativeYCoordinates ( allDimensions , myDims , childIds ) ; childIds . forEach ( ( childId , index ) => { renderChildConnector ( { x : foldingIconPos . x + FOLDING_ICON_RADIUS , y : foldingIconPos . y , } , { x : childrenX , y : foldingIconPos . y + relativeChildYCoords [ index ] . bubbleCenter , } ) ; renderNodesRecursively ( allDimensions , childId , { x : childrenX , y : foldingIconPos . y + relativeChildYCoords [ index ] . bubbleTop , } ) ; } ) ; } } } param myDims param bubblePos param nodeId function renderNodeContentsAndBubble ( myDims , bubblePos , nodeId ) { const linesForThisNode = getNodeLines ( documentState . getNodeContents ( nodeId ) ) ; let textY = bubblePos . y + fontSize + NODE_PADDING . y / 2 ; linesForThisNode . forEach ( ( line ) => { ctx . fillText ( line , bubblePos . x + NODE_PADDING . x , textY ) ; textY += fontSize + NODE_LINES_VERTICAL_PADDING ; } ) ; if ( documentState . getSelectedNodeId ( ) === nodeId ) { ctx . strokeStyle = '#0000ff' ; ctx . lineWidth = 2 ; } else { ctx . strokeStyle = '#000000' ; ctx . lineWidth = 1 ; } roundedRectangle ( bubblePos . x , bubblePos . y , myDims . bubbleWidth , myDims . bubbleHeight ) ; ctx . strokeStyle = '#000000' ; ctx . lineWidth = 1 ; clickableNodes . push ( { id : nodeId , x : bubblePos . x , y : bubblePos . y , width : myDims . bubbleWidth , height : myDims . bubbleHeight , } ) ; } param allDimensions param nodeId returns function safeGetDimensions ( allDimensions , nodeId ) { const dimensions = allDimensions . get ( nodeId ) ; if ( dimensions !== undefined ) return dimensions ; throw new Error ( `safeGetDimensions(): nodeID '${ nodeId }' is not present` ) ; } param x param y param width param height function roundedRectangle ( x , y , width , height ) { const radius = 5 ; const topLeft = { x , y , } ; const topRight = { x : x + width , y , } ; const bottomLeft = { x , y : y + height , } ; const bottomRight = { x : x + width , y : y + height , } ; ctx . beginPath ( ) ; ctx . arc ( topLeft . x + radius , topLeft . y + radius , radius , Math . PI , 1.5 * Math . PI ) ; ctx . lineTo ( topRight . x - radius , topRight . y ) ; ctx . arc ( topRight . x - radius , topRight . y + radius , radius , 1.5 * Math . PI , 2 * Math . PI ) ; ctx . lineTo ( bottomRight . x , bottomRight . y - radius ) ; ctx . arc ( bottomRight . x - radius , bottomRight . y - radius , radius , 0 , 0.5 * Math . PI ) ; ctx . lineTo ( bottomLeft . x + radius , bottomLeft . y ) ; ctx . arc ( bottomLeft . x + radius , bottomLeft . y - radius , radius , 0.5 * Math . PI , Math . PI ) ; ctx . lineTo ( topLeft . x , topLeft . y + radius ) ; ctx . stroke ( ) ; } function saveDimensionsFromAttrs ( attrs ) { currentDocDimensions . width = attrs . documentDimensions . width ; currentDocDimensions . height = attrs . documentDimensions . height ; } return { oncreate : ( vnode ) => { canvasElement = vnode . dom as HTMLCanvasElement ; ctx = canvasElement . getContext ( '2d' ) as CanvasRenderingContext2D ; saveDimensionsFromAttrs ( vnode . attrs ) ; ctx . scale ( devicePixelRatio , devicePixelRatio ) ; ctx . strokeStyle = '#000000' ; ctx . fillStyle = '#000000' ; ctx . font = `${ fontSize }px sans-serif` ; const rootNodeId = documentState . getRootNodeId ( ) ; const allDimensions = new Map ( ) ; calculateDimensions ( allDimensions , rootNodeId ) ; renderNodesRecursively ( allDimensions , rootNodeId , { x : 10 , y : vnode . attrs . documentDimensions . height / 2 , } ) ; } , onupdate : ( vnode ) => { if ( vnode . attrs . performReset ) { ctx . translate ( - cumulativeCanvasTranslation . x , - cumulativeCanvasTranslation . y ) ; cumulativeCanvasTranslation . x = 0 ; cumulativeCanvasTranslation . y = 0 ; } fontSize = uiState . getCurrentFontSize ( ) ; if ( currentDocDimensions . width !== vnode . attrs . documentDimensions . width || currentDocDimensions . height !== vnode . attrs . documentDimensions . height ) { saveDimensionsFromAttrs ( vnode . attrs ) ; ctx . scale ( devicePixelRatio , devicePixelRatio ) ; ctx . translate ( cumulativeCanvasTranslation . x , cumulativeCanvasTranslation . y ) ; } const MAX_PREV_TRANSLATION = 4000 ; ctx . clearRect ( - MAX_PREV_TRANSLATION , - MAX_PREV_TRANSLATION , vnode . attrs . documentDimensions . width + 2 * MAX_PREV_TRANSLATION , vnode . attrs . documentDimensions . height + 2 * MAX_PREV_TRANSLATION ) ; clickableFoldingIcons = [ ] ; clickableNodes = [ ] ; ctx . strokeStyle = '#000000' ; ctx . fillStyle = '#000000' ; ctx . font = `${ fontSize }px sans-serif` ; const rootNodeId = documentState . getRootNodeId ( ) ; const allDimensions = new Map ( ) ; calculateDimensions ( allDimensions , rootNodeId ) ; renderNodesRecursively ( allDimensions , rootNodeId , { x : 10 , y : vnode . attrs . documentDimensions . height / 2 , } ) ; } , view : ( { attrs } ) => { const cssPixelsWidth = attrs . documentDimensions . width ; const cssPixelsHeight = attrs . documentDimensions . height ; const canvasActualWidth = cssPixelsWidth * devicePixelRatio ; const canvasActualHeight = cssPixelsHeight * devicePixelRatio ; const handlers = { onclick : onClick , onmousedown : onMouseDown , onmousemove : dragging ? onMouseMove : undefined , onmouseout : dragging ? onMouseOut : undefined , onmouseup : onMouseUp , ontouchend : onTouchEnd , ontouchmove : dragging ? onTouchMove : undefined , ontouchstart : onTouchStart , } ; return m ( 'canvas' , { width : canvasActualWidth , height : canvasActualHeight , style : { border : '1px solid black' , width : `${ cssPixelsWidth }px` , height : `${ cssPixelsHeight }px` , } , ... handlers , } ) ; } , } ; } export default DisplayedDocument ;