import { assert } from "@fluidframework/common-utils" ; import { randomId , TokenList , TagName } from "@fluid-example/flow-util-lib" ; import { LazyLoadedDataObject , LazyLoadedDataObjectFactory } from "@fluidframework/data-object-base" ; import { IFluidHandle } from "@fluidframework/core-interfaces" ; import { createInsertSegmentOp , createRemoveRangeOp , IMergeTreeRemoveMsg , ISegment , LocalReference , Marker , MergeTreeDeltaType , PropertySet , ReferencePosition , ReferenceType , reservedMarkerIdKey , reservedRangeLabelsKey , reservedTileLabelsKey , TextSegment , } from "@fluidframework/merge-tree" ; import { IFluidDataStoreContext , IFluidDataStoreFactory } from "@fluidframework/runtime-definitions" ; import { SharedString , SharedStringSegment , SequenceMaintenanceEvent , SequenceDeltaEvent , } from "@fluidframework/sequence" ; import { ISharedDirectory , SharedDirectory } from "@fluidframework/map" ; import { IFluidHTMLOptions } from "@fluidframework/view-interfaces" ; import { IEvent } from "@fluidframework/common-definitions" ; import { clamp , emptyArray } from "../util" ; import { IHTMLAttributes } from "../util/attr" ; import { documentType } from "../package" ; import { debug } from "./debug" ; import { SegmentSpan } from "./segmentspan" ; export const enum DocSegmentKind { text = "text" , paragraph = "<p>" , lineBreak = "<br>" , beginTags = "<t>" , inclusion = "<?>" , endTags = "</>" , endOfText = "eot" } const tilesAndRanges = new Set ( [ DocSegmentKind . paragraph , DocSegmentKind . lineBreak , DocSegmentKind . beginTags , DocSegmentKind . inclusion ] ) ; const enum Workaround { checkpoint = "*" } export const enum DocTile { paragraph = DocSegmentKind . paragraph , checkpoint = Workaround . checkpoint } export const getDocSegmentKind = ( segment ) : DocSegmentKind => { if ( segment === endOfTextSegment ) { return DocSegmentKind . endOfText ; } if ( TextSegment . is ( segment ) ) { return DocSegmentKind . text ; } else if ( Marker . is ( segment ) ) { const markerType = segment . refType ; switch ( markerType ) { case ReferenceType . Tile : case ReferenceType . Tile | ReferenceType . NestBegin : const kind = ( segment . hasRangeLabels ( ) ? segment . getRangeLabels ( ) [ 0 ] : segment . getTileLabels ( ) [ 0 ] ) as DocSegmentKind ; assert ( tilesAndRanges . has ( kind ) , `Unknown tile/range label.` ) ; return kind ; default : assert ( markerType === ( ReferenceType . Tile | ReferenceType . NestEnd ) , "unexpected marker type" ) ; assert ( segment . getRangeLabels ( ) [ 0 ] === DocSegmentKind . beginTags , `Unknown refType '${ markerType }'.` ) ; return DocSegmentKind . endTags ; } } } ; const empty = Object . freeze ( { } ) ; export const getCss = ( segment ) : Readonly < { style ? : string ; classList ? : string ; } > => segment . properties || empty ; export const getComponentOptions = ( segment ) : IFluidHTMLOptions | undefined => ( segment . properties && segment . properties . componentOptions ) || empty ; type LeafAction = ( position : number , segment : ISegment , startOffset : number , endOffset : number ) => boolean ; /**
 * Used by 'FlowDocument.visitRange'.  Uses the otherwise unused 'accum' object to pass the
 * leaf action callback, allowing us to simplify the the callback signature and while (maybe)
 * avoiding unnecessary allocation to wrap the given 'callback'.
 */ const accumAsLeafAction = ( segment , position , refSeq , clientId , startOffset , endOffset , accum ? ) => ( accum ) ( position , segment , startOffset , endOffset ) ; const endOfTextSegment = undefined as unknown as SharedStringSegment ; export interface IFlowDocumentEvents extends IEvent { ( event , listener ) ; ( event , listener ) ; } const textId = "text" ; export class FlowDocument extends LazyLoadedDataObject < ISharedDirectory , IFlowDocumentEvents > { private static readonly factory = new LazyLoadedDataObjectFactory < FlowDocument > ( documentType , FlowDocument , SharedDirectory . getFactory ( ) , [ SharedString . getFactory ( ) ] ) ; public static getFactory ( ) { return FlowDocument . factory ; } public static async create ( parentContext , props ? ) { return FlowDocument . factory . create ( parentContext , props ) ; } public get length ( ) { return this . sharedString . getLength ( ) ; } private static readonly paragraphProperties = Object . freeze ( { [ reservedTileLabelsKey ] : [ DocSegmentKind . paragraph , DocTile . checkpoint ] , tag : TagName . p } ) ; private static readonly lineBreakProperties = Object . freeze ( { [ reservedTileLabelsKey ] : [ DocSegmentKind . lineBreak , DocTile . checkpoint ] } ) ; private static readonly inclusionProperties = Object . freeze ( { [ reservedTileLabelsKey ] : [ DocSegmentKind . inclusion , DocTile . checkpoint ] } ) ; private static readonly tagsProperties = Object . freeze ( { [ reservedTileLabelsKey ] : [ DocSegmentKind . inclusion , DocTile . checkpoint ] , [ reservedRangeLabelsKey ] : [ DocSegmentKind . beginTags ] } ) ; private sharedString : SharedString ; public create ( ) { Object . assign ( this . runtime , { options : { ... ( this . runtime . options || { } ) , blockUpdateMarkers : true } } ) ; this . sharedString = SharedString . create ( this . runtime ) ; this . root . set ( textId , this . sharedString . handle ) ; this . forwardEvent ( this . sharedString , "sequenceDelta" , "maintenance" ) ; } public async load ( ) { Object . assign ( this . runtime , { options : { ... ( this . runtime . options || { } ) , blockUpdateMarkers : true } } ) ; const handle = this . root . get < IFluidHandle < SharedString > > ( textId ) ; if ( handle === undefined ) { throw new Error ( "String not initialized properly" ) ; } this . sharedString = await handle . get ( ) ; this . forwardEvent ( this . sharedString , "sequenceDelta" , "maintenance" ) ; } public async getComponentFromMarker ( marker ) { return marker . properties . handle . get ( ) ; } public getSegmentAndOffset ( position ) { return position === this . length ? { segment : endOfTextSegment , offset : 0 } : this . sharedString . getContainingSegment ( position ) ; } public getPosition ( segment ) { return segment === endOfTextSegment ? this . length : this . sharedString . getPosition ( segment ) ; } public addLocalRef ( position ) { if ( position >= this . length ) { return this . sharedString . createPositionReference ( endOfTextSegment , 0 , ReferenceType . Transient ) ; } const { segment , offset } = this . getSegmentAndOffset ( position ) ; const localRef = this . sharedString . createPositionReference ( segment , offset , ReferenceType . SlideOnRemove ) ; return localRef ; } public removeLocalRef ( localRef ) { const segment = localRef . getSegment ( ) ; if ( segment !== endOfTextSegment ) { this . sharedString . removeLocalReference ( localRef ) ; } } public localRefToPosition ( localRef ) { if ( localRef . getSegment ( ) === endOfTextSegment ) { return this . length ; } return localRef . toPosition ( ) ; } public insertText ( position , text ) { debug ( `insertText(${ position },"${ text }")` ) ; this . sharedString . insertText ( position , text ) ; } public replaceWithText ( start , end , text ) { debug ( `replaceWithText(${ start }, ${ end }, "${ text }")` ) ; this . sharedString . replaceText ( start , end , text ) ; } public remove ( start , end ) { let _start = start ; debug ( `remove(${ _start },${ end })` ) ; const ops = [ ] ; this . visitRange ( ( position , segment ) => { switch ( getDocSegmentKind ( segment ) ) { case DocSegmentKind . beginTags : { const endTag = this . getEnd ( segment as Marker ) ; const endPos = this . getPosition ( endTag ) ; console . assert ( position < endPos ) ; if ( ! ( endPos < end ) ) { debug ( `  also remove end tag '</${ endTag . properties . tag }>' at ${ endPos }.` ) ; ops . push ( createRemoveRangeOp ( endPos , endPos + 1 ) ) ; } break ; } case DocSegmentKind . endTags : { const startTag = this . getStart ( segment as Marker ) ; const startPos = this . getPosition ( startTag ) ; console . assert ( startPos < position ) ; if ( ! ( _start <= startPos ) ) { debug ( `  exclude end tag '</${ segment . properties . tag }>' at ${ position }.` ) ; if ( _start !== position ) { ops . push ( createRemoveRangeOp ( _start , position ) ) ; } _start = position + 1 ; } break ; } default : break ; } return true ; } , _start , end ) ; if ( _start !== end ) { ops . push ( createRemoveRangeOp ( _start , end ) ) ; } ops . sort ( ( left , right ) => right . pos1 - left . pos1 ) ; this . sharedString . groupOperation ( { ops , type : MergeTreeDeltaType . GROUP } ) ; } public insertParagraph ( position , tag ? ) { debug ( `insertParagraph(${ position })` ) ; this . sharedString . insertMarker ( position , ReferenceType . Tile , Object . freeze ( { ... FlowDocument . paragraphProperties , tag } ) ) ; } public insertLineBreak ( position ) { debug ( `insertLineBreak(${ position })` ) ; this . sharedString . insertMarker ( position , ReferenceType . Tile , FlowDocument . lineBreakProperties ) ; } public insertComponent ( position , handle , view , componentOptions , style ? , classList ? ) { this . sharedString . insertMarker ( position , ReferenceType . Tile , Object . freeze ( { ... FlowDocument . inclusionProperties , componentOptions , handle , style , classList : classList && classList . join ( " " ) , view } ) ) ; } public setFormat ( position , tag ) { const { start } = this . findParagraph ( position ) ; if ( start < this . length ) { const pgSeg = this . getSegmentAndOffset ( start ) . segment ; if ( getDocSegmentKind ( pgSeg ) === DocSegmentKind . paragraph ) { pgSeg . properties . tag = tag ; this . annotate ( start , start + 1 , { tag } ) ; return ; } } this . insertParagraph ( start , tag ) ; } public insertTags ( tags , start , end = start ) { const ops = [ ] ; const id = randomId ( ) ; const endMarker = new Marker ( ReferenceType . Tile | ReferenceType . NestEnd ) ; endMarker . properties = Object . freeze ( { ... FlowDocument . tagsProperties , [ reservedMarkerIdKey ] : `end-${ id }` } ) ; ops . push ( createInsertSegmentOp ( end , endMarker ) ) ; const beginMarker = new Marker ( ReferenceType . Tile | ReferenceType . NestBegin ) ; beginMarker . properties = Object . freeze ( { ... FlowDocument . tagsProperties , tags , [ reservedMarkerIdKey ] : `begin-${ id }` } ) ; ops . push ( createInsertSegmentOp ( start , beginMarker ) ) ; this . sharedString . groupOperation ( { ops , type : MergeTreeDeltaType . GROUP } ) ; } public getTags ( position ) { const tags = this . sharedString . getStackContext ( position , [ DocSegmentKind . beginTags ] ) [ DocSegmentKind . beginTags ] ; return ( tags && tags . items ) || emptyArray ; } public getStart ( marker ) { return this . getOppositeMarker ( marker , 3 , "begin" ) ; } public getEnd ( marker ) { return this . getOppositeMarker ( marker , 5 , "end" ) ; } public annotate ( start , end , props ) { this . sharedString . annotateRange ( start , end , props ) ; } public setCssStyle ( start , end , style ) { this . sharedString . annotateRange ( start , end , { style } ) ; } public addCssClass ( start , end , ... classNames ) { if ( classNames . length > 0 ) { const newClasses = classNames . join ( " " ) ; this . updateCssClassList ( start , end , ( classList ) => TokenList . set ( classList , newClasses ) ) ; } } public removeCssClass ( start , end , ... classNames ) { this . updateCssClassList ( start , end , ( classList ) => classNames . reduce ( ( updatedList , className ) => TokenList . unset ( updatedList , className ) , classList ) ) ; } public toggleCssClass ( start , end , ... classNames ) { const toAdd = classNames . slice ( 0 ) ; const toRemove = new Set < string > ( ) ; this . updateCssClassList ( start , end , ( classList ) => { TokenList . computeToggle ( classList , toAdd , toRemove ) ; return classList ; } ) ; this . removeCssClass ( start , end , ... toRemove ) ; this . addCssClass ( start , end , ... toAdd ) ; } public setAttr ( start , end , attr ) { this . sharedString . annotateRange ( start , end , { attr } ) ; } public findTile ( position , tileType , preceding ) { return this . sharedString . findTile ( position , tileType as unknown as string , preceding ) ; } public findParagraph ( position ) { const maybeStart = this . findTile ( position , DocTile . paragraph , true ) ; const start = maybeStart ? maybeStart . pos : 0 ; const maybeEnd = this . findTile ( position , DocTile . paragraph , false ) ; const end = maybeEnd ? maybeEnd . pos + 1 : this . length ; return { start , end } ; } public visitRange ( callback , start = 0 , end = this . length ) { const _end = clamp ( 0 , end , this . length ) ; const _start = clamp ( 0 , start , end ) ; if ( ! ( _start < _end ) ) { return ; } this . sharedString . walkSegments ( accumAsLeafAction , _start , _end , callback ) ; } public getText ( start ? , end ? ) { return this . sharedString . getText ( start , end ) ; } public toString ( ) { const s = [ ] ; this . visitRange ( ( position , segment ) => { let _segment = segment ; const kind = getDocSegmentKind ( _segment ) ; switch ( kind ) { case DocSegmentKind . text : s . push ( ( _segment as TextSegment ) . text ) ; break ; case DocSegmentKind . beginTags : for ( const tag of _segment . properties . tags ) { s . push ( `<${ tag }>` ) ; } break ; case DocSegmentKind . endTags : _segment = this . getStart ( _segment as Marker ) ; const tags = _segment . properties . tags . slice ( ) . reverse ( ) ; for ( const tag of tags ) { s . push ( `</${ tag }>` ) ; } break ; default : s . push ( kind ) ; } return true ; } ) ; return s . join ( "" ) ; } private getOppositeMarker ( marker , oldPrefixLength , newPrefix ) { return this . sharedString . getMarkerFromId ( `${ newPrefix }${ marker . getId ( ) . slice ( oldPrefixLength ) }` ) ; } private updateCssClassList ( start , end , callback ) { const updates = [ ] ; this . visitRange ( ( position , segment , startOffset , endOffset ) => { const oldList = getCss ( segment ) . classList ; const newList = callback ( oldList ) ; if ( newList !== oldList ) { updates . push ( { classList : newList , span : new SegmentSpan ( position , segment , startOffset , endOffset ) } ) ; } return true ; } , start , end ) ; for ( const { span , classList } of updates ) { this . annotate ( span . startPosition , span . endPosition , { classList } ) ; } } }