/*! * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. */ import type { IChannelAttributes, IFluidDataStoreRuntime, } from "@fluidframework/datastore-definitions/internal"; import type { IMergeTreeTextHelper, IRelativePosition, ISegment, ISegmentAction, PropertySet, ReferenceType, } from "@fluidframework/merge-tree/internal"; import { Marker, TextSegment, refHasTileLabel } from "@fluidframework/merge-tree/internal"; import { SharedSegmentSequence, type ISharedSegmentSequence } from "./sequence.js"; import { SharedStringFactory, type SharedStringOptions } from "./sequenceFactory.js"; /** * Fluid object interface describing access methods on a SharedString * @legacy @beta */ export interface ISharedString extends ISharedSegmentSequence { /** * Inserts the text at the position. * @param pos - The position to insert the text at * @param text - The text to insert * @param props - The properties of the text */ insertText(pos: number, text: string, props?: PropertySet): void; /** * Inserts a marker at the position. * @param pos - The position to insert the marker at * @param refType - The reference type of the marker * @param props - The properties of the marker */ insertMarker(pos: number, refType: ReferenceType, props?: PropertySet): void; /** * Inserts a marker at a relative position. * @param relativePos1 - The relative position to insert the marker at * @param refType - The reference type of the marker * @param props - The properties of the marker */ insertMarkerRelative( relativePos1: IRelativePosition, refType: ReferenceType, props?: PropertySet, ): void; /** * Inserts the text at the position. * @param relativePos1 - The relative position to insert the text at * @param text - The text to insert * @param props - The properties of text */ insertTextRelative(relativePos1: IRelativePosition, text: string, props?: PropertySet): void; /** * Replaces a range with the provided text. * @param start - The inclusive start of the range to replace * @param end - The exclusive end of the range to replace * @param text - The text to replace the range with * @param props - Optional. The properties of the replacement text */ replaceText(start: number, end: number, text: string, props?: PropertySet): void; /** * Removes the text in the given range. * @param start - The inclusive start of the range to remove * @param end - The exclusive end of the range to replace * @returns the message sent. */ removeText(start: number, end: number): void; /** * Annotates the marker with the provided properties. * @param marker - The marker to annotate * @param props - The properties to annotate the marker with */ annotateMarker(marker: Marker, props: PropertySet): void; /** * Searches a string for the nearest marker in either direction to a given start position. * The search will include the start position, so markers at the start position are valid * results of the search. * @param startPos - Position at which to start the search * @param markerLabel - Label of the marker to search for * @param forwards - Whether the desired marker comes before (false) or after (true) `startPos`. Default true. */ searchForMarker( startPos: number, markerLabel: string, forwards?: boolean, ): Marker | undefined; /** * Retrieve text from the SharedString in string format. * @param start - The starting index of the text to retrieve, or 0 if omitted. * @param end - The ending index of the text to retrieve, or the end of the string if omitted * @returns The requested text content as a string. */ getText(start?: number, end?: number): string; /** * Adds spaces for markers and handles, so that position calculations account for them. */ getTextWithPlaceholders(start?: number, end?: number): string; getTextRangeWithMarkers(start: number, end: number): string; /** * Looks up and returns a `Marker` using its id. Returns `undefined` if there is no marker with the provided * id in this `SharedString`. */ getMarkerFromId(id: string): ISegment | undefined; } /** * @legacy @beta */ export type SharedStringSegment = TextSegment | Marker; /** * The Shared String is a specialized data structure for handling collaborative * text. It is based on a more general Sequence data structure but has * additional features that make working with text easier. * * In addition to text, a Shared String can also contain markers. Markers can be * used to store metadata at positions within the text, like the details of an * image or Fluid object that should be rendered with the text. * @internal */ export class SharedStringClass extends SharedSegmentSequence implements ISharedString { public get ISharedString(): ISharedString { return this; } private readonly mergeTreeTextHelper: IMergeTreeTextHelper; constructor( document: IFluidDataStoreRuntime, public id: string, attributes: IChannelAttributes, options: SharedStringOptions = {}, ) { super( document, id, attributes, SharedStringFactory.segmentFromSpec as any, options.newMergeTreeSnapshotFormat, ); this.mergeTreeTextHelper = this.client.createTextHelper(); } public insertMarkerRelative( relativePos1: IRelativePosition, refType: ReferenceType, props?: PropertySet, ): void { const pos = this.posFromRelativePos(relativePos1); this.guardReentrancy(() => this.client.insertSegmentLocal(pos, Marker.make(refType, props)), ); } public insertMarker(pos: number, refType: ReferenceType, props?: PropertySet): void { this.guardReentrancy(() => this.client.insertSegmentLocal(pos, Marker.make(refType, props)), ); } public insertTextRelative( relativePos1: IRelativePosition, text: string, props?: PropertySet, ): void { const pos = this.posFromRelativePos(relativePos1); this.guardReentrancy(() => this.client.insertSegmentLocal(pos, TextSegment.make(text, props)), ); } public insertText(pos: number, text: string, props?: PropertySet): void { this.guardReentrancy(() => this.client.insertSegmentLocal(pos, TextSegment.make(text, props)), ); } public replaceText(start: number, end: number, text: string, props?: PropertySet): void { this.replaceRange(start, end, TextSegment.make(text, props)); } public removeText(start: number, end: number): void { this.removeRange(start, end); } public annotateMarker(marker: Marker, props: PropertySet): void { this.guardReentrancy(() => this.client.annotateMarker(marker, props)); } public searchForMarker( startPos: number, markerLabel: string, forwards = true, ): Marker | undefined { return this.client.searchForMarker(startPos, markerLabel, forwards); } public getText(start?: number, end?: number) { const collabWindow = this.client.getCollabWindow(); return this.mergeTreeTextHelper.getText(collabWindow.localPerspective, "", start, end); } public getTextWithPlaceholders(start?: number, end?: number) { const collabWindow = this.client.getCollabWindow(); return this.mergeTreeTextHelper.getText(collabWindow.localPerspective, " ", start, end); } public getTextRangeWithMarkers(start: number, end: number) { const collabWindow = this.client.getCollabWindow(); return this.mergeTreeTextHelper.getText(collabWindow.localPerspective, "*", start, end); } public getMarkerFromId(id: string): ISegment | undefined { return this.client.getMarkerFromId(id); } } interface ITextAndMarkerAccumulator { parallelText: string[]; parallelMarkers: Marker[]; parallelMarkerLabel: string; placeholder?: string; tagsInProgress: string[]; textSegment: TextSegment; } /** * Splits the text into regions ending with markers with the given `label`. * @param sharedString - String to retrieve text and markers from * @param label - label to split on * @returns Two parallel lists of text and markers, split by markers with the provided `label`. * For example: * ```typescript * // Say sharedstring has contents "helloworldmissing". * const { parallelText, parallelMarkers } = getTextAndMarkers(sharedString, "paragraph"); * // parallelText === ["hello", "world"] * // parallelMarkers === [, ] * // Note parallelText does not include "missing". * ``` * @internal */ export function getTextAndMarkers( sharedString: ISharedString, label: string, start?: number, end?: number, ): { parallelText: string[]; parallelMarkers: Marker[]; } { const accum: ITextAndMarkerAccumulator = { parallelMarkerLabel: label, parallelMarkers: [], parallelText: [], tagsInProgress: [], textSegment: new TextSegment(""), }; sharedString.walkSegments(gatherTextAndMarkers, start, end, accum); return { parallelText: accum.parallelText, parallelMarkers: accum.parallelMarkers }; } const gatherTextAndMarkers: ISegmentAction = ( segment: ISegment, pos: number, refSeq: number, clientId: number, start: number, end: number, accumText: ITextAndMarkerAccumulator, ) => { const { placeholder, tagsInProgress, textSegment } = accumText; if (TextSegment.is(segment)) { let beginTags = ""; let endTags = ""; // TODO: let clients pass in function to get tag const tags = [] as string[]; const initTags = [] as string[]; if (segment.properties?.["font-weight"]) { tags.push("b"); } if (segment.properties?.["text-decoration"]) { tags.push("u"); } const remTags = [] as string[]; if (tags.length > 0) { for (const tag of tags) { if (!tagsInProgress.includes(tag)) { beginTags += `<${tag}>`; initTags.push(tag); } } for (const accumTag of tagsInProgress) { if (!tags.includes(accumTag)) { endTags += ``; remTags.push(accumTag); } } for (const initTag of initTags.reverse()) { tagsInProgress.push(initTag); } } else { for (const accumTag of tagsInProgress) { endTags += ``; remTags.push(accumTag); } } for (const remTag of remTags) { const remdex = tagsInProgress.indexOf(remTag); if (remdex >= 0) { tagsInProgress.splice(remdex, 1); } } textSegment.text += endTags; textSegment.text += beginTags; if (start <= 0 && end >= segment.text.length) { textSegment.text += segment.text; } else { const seglen = segment.text.length; const _start = start < 0 ? 0 : start; const _end = end >= seglen ? undefined : end; textSegment.text += segment.text.substring(_start, _end); } } else { if (placeholder && placeholder.length > 0) { const placeholderText = // eslint-disable-next-line @typescript-eslint/no-base-to-string placeholder === "*" ? `\n${segment}` : placeholder.repeat(segment.cachedLength); textSegment.text += placeholderText; } else { const marker = segment as Marker; if (refHasTileLabel(marker, accumText.parallelMarkerLabel)) { accumText.parallelMarkers.push(marker); accumText.parallelText.push(textSegment.text); textSegment.text = ""; } } } return true; };