UNPKG

@itwin/core-common

Version:

iTwin.js components common to frontend and backend

590 lines • 23.4 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /** @packageDocumentation * @module Annotation */ import { ListMarkerEnumerator, TextStyleSettings } from "./TextStyle"; function clearStyleOverrides(component, options) { component.styleOverrides = {}; if (!options?.preserveChildrenOverrides) { for (const child of component.children) { child.clearStyleOverrides(options); } } } /** * Abstract representation of any of the building blocks that make up a [[TextBlock]] document - namely [[Run]]s and [[StructuralTextBlockComponent]]s. * The [[TextBlock]] can specify an [AnnotationTextStyle]($backend) that formats its contents. * Each component can specify an optional [[styleOverrides]] to customize that formatting. * @beta */ export class TextBlockComponent { _styleOverrides; /** @internal */ constructor(props) { this._styleOverrides = structuredClone(props?.styleOverrides ?? {}); } /** Deviations in individual properties of the [[TextStyleSettings]] in the [AnnotationTextStyle]($backend). * For example, if the style uses the "Arial" font, you can override that by settings `styleOverrides.font.name` to "Comic Sans". * @see [[clearStyleOverrides]] to reset this to an empty object. */ get styleOverrides() { return this._styleOverrides; } set styleOverrides(overrides) { this._styleOverrides = structuredClone(overrides); } /** Reset any [[styleOverrides]] applied to this component. */ clearStyleOverrides(_options) { this.styleOverrides = {}; } /** Returns true if [[styleOverrides]] specifies any deviations from the [[TextBlock]]'s [AnnotationTextStyle]($backend). */ get overridesStyle() { return Object.keys(this.styleOverrides).length > 0; } /** * Returns true if the string representation of this component consists only of whitespace characters. * Useful for checking if the component is visually empty (producing no graphics) or contains only spaces, tabs, or line breaks. */ get isWhitespace() { return /^\s*$/g.test(this.stringify()); } ; /** Convert this component to its JSON representation. */ toJSON() { return { styleOverrides: structuredClone(this.styleOverrides), }; } /** Returns true if `this` is equivalent to `other`. */ equals(other) { const mySettings = TextStyleSettings.fromJSON(this.styleOverrides); const otherSettings = TextStyleSettings.fromJSON(other.styleOverrides); return mySettings.equals(otherSettings); } } /** A sequence of characters within a [[Paragraph]] that share a single style. Runs are the leaf nodes of a [[TextBlock]] document. When laid out for display, a single run may span * multiple lines, but it will never contain different styling. * Use the `type` field to discriminate between the different kinds of runs. * @beta */ export var Run; (function (Run) { /** Create a run from its JSON representation. * @see [[TextRun.create]], [[FractionRun.create]], [[FieldRun.create]], [[TabRun.create]], and [[LineBreakRun.create]] to create a run directly. */ function fromJSON(props) { switch (props.type) { case "field": return FieldRun.create(props); case "fraction": return FractionRun.create(props); case "linebreak": return LineBreakRun.create(props); case "tab": return TabRun.create(props); case "text": return TextRun.create(props); } } Run.fromJSON = fromJSON; })(Run || (Run = {})); /** The most common type of [[Run]], containing a sequence of characters to be displayed using a single style. * @beta */ export class TextRun extends TextBlockComponent { /** Discriminator field for the [[Run]] union. */ type = "text"; /** The sequence of characters to be displayed by the run. */ content; /** Whether to display [[content]] as a subscript, superscript, or normally. */ baselineShift; constructor(props) { super(props); this.content = props?.content ?? ""; this.baselineShift = props?.baselineShift ?? "none"; } clone() { return new TextRun(this.toJSON()); } toJSON() { return { ...super.toJSON(), type: "text", content: this.content, baselineShift: this.baselineShift, }; } static create(props) { return new TextRun({ ...props, type: "text" }); } get isEmpty() { return this.stringify().length === 0; } /** Simply returns [[content]]. */ stringify() { return this.content; } equals(other) { return other instanceof TextRun && this.content === other.content && this.baselineShift === other.baselineShift && super.equals(other); } } /** A [[Run]] containing a numeric ratio to be displayed as a numerator and denominator separated by a horizontal or diagonal bar. * @note The [[numerator]] and [[denominator]] are stored as strings. They are not technically required to contain a numeric representation. * @beta */ export class FractionRun extends TextBlockComponent { /** Discriminator field for the [[Run]] union. */ type = "fraction"; /** The fraction's numerator. */ numerator; /** The fraction's denominator. */ denominator; constructor(props) { super(props); this.numerator = props?.numerator ?? ""; this.denominator = props?.denominator ?? ""; } toJSON() { return { ...super.toJSON(), type: "fraction", numerator: this.numerator, denominator: this.denominator, }; } clone() { return new FractionRun(this.toJSON()); } static create(props) { return new FractionRun({ ...props, type: "fraction" }); } get isEmpty() { return this.stringify().length === 0; } /** Formats the fraction as a string with the [[numerator]] and [[denominator]] separated by [[TextBlockStringifyOptions.fractionSeparator]]. */ stringify(options) { const sep = options?.fractionSeparator ?? "/"; return `${this.numerator}${sep}${this.denominator}`; } equals(other) { return other instanceof FractionRun && this.numerator === other.numerator && this.denominator === other.denominator && super.equals(other); } } /** A [[Run]] that represents the end of a line of text within a [[Paragraph]]. It contains no content of its own - it simply causes subsequent content to display on a new line. * @beta */ export class LineBreakRun extends TextBlockComponent { /** Discriminator field for the [[Run]] union. */ type = "linebreak"; constructor(props) { super(props); } toJSON() { return { ...super.toJSON(), type: "linebreak", }; } static create(props) { return new LineBreakRun({ ...props, type: "linebreak" }); } clone() { return new LineBreakRun(this.toJSON()); } get isEmpty() { return this.stringify().length === 0; } /** Simply returns [[TextBlockStringifyOptions.lineBreak]]. */ stringify(options) { return options?.lineBreak ?? " "; } equals(other) { return other instanceof LineBreakRun && super.equals(other); } } /** A [[TabRun]] is used to shift the next tab stop. * @note Only left-justified tabs are supported at this tab. * @beta */ export class TabRun extends TextBlockComponent { /** Discriminator field for the [[Run]] union. */ type = "tab"; toJSON() { return { ...super.toJSON(), type: "tab", }; } clone() { return new TabRun(this.toJSON()); } static create(props) { return new TabRun(props); } get isEmpty() { return this.stringify().length === 0; } /** * Converts a [[TabRun]] to its string representation. * If the `tabsAsSpaces` option is provided, returns a string of spaces of the specified length. * Otherwise, returns a tab character ("\t"). */ stringify(options) { if (options?.tabsAsSpaces) { return " ".repeat(options.tabsAsSpaces); } return "\t"; } equals(other) { return other instanceof TabRun && super.equals(other); } } /** A [[Run]] that displays the formatted value of a property of some [Element]($backend). * When a [[TextBlock]] containing a [[FieldRun]] is written into the iModel as an [ITextAnnotation]($backend) element, * a dependency is established between the two elements via the [ElementDrivesTextAnnotation]($backend) relationship such that * whenever the source element specified by [[propertyHost]] is modified or the `ITextAnnotation` element is inserted or updated in the iModel, * the field(s) in the `ITextAnnotation` element are automatically * recalculated, causing their [[cachedContent]] to update. If the field's display string cannot be evaluated (for example, because the specified element or * property does not exist), then its cached content is set to [[FieldRun.invalidContentIndicator]]. * A [[FieldRun]] displays its [[cachedContent]] in the same way that [[TextRun]]s display their `content`, including word wrapping where appropriate. * @beta */ export class FieldRun extends TextBlockComponent { /** Display string used to signal an error in computing the field's value. */ static invalidContentIndicator = "####"; /** Discriminator field for the [[Run]] union. */ type = "field"; /** The element and BIS class containing the property described by [[propertyPath]]. */ propertyHost; /** Describes how to obtain the property value from [[propertyHost]]. */ propertyPath; /** Specifies how to format the property value obtained from [[propertyPath]] into a string to be stored in [[cachedContent]]. * The specific options used depend upon the [[FieldPropertyType]]. */ formatOptions; _cachedContent; /** The field's most recently evaluated display string. */ get cachedContent() { return this._cachedContent; } /** @internal Used by core-backend when re-evaluating field content. */ setCachedContent(content) { this._cachedContent = content ?? FieldRun.invalidContentIndicator; } constructor(props) { super(props); this._cachedContent = props.cachedContent ?? FieldRun.invalidContentIndicator; this.propertyHost = props.propertyHost; this.propertyPath = props.propertyPath; this.formatOptions = props.formatOptions; } /** Create a FieldRun from its JSON representation. */ static create(props) { return new FieldRun({ ...props, propertyHost: { ...props.propertyHost }, propertyPath: structuredClone(props.propertyPath), formatOptions: structuredClone(props.formatOptions), type: "field", }); } /** Convert the FieldRun to its JSON representation. */ toJSON() { const json = { ...super.toJSON(), type: "field", propertyHost: { ...this.propertyHost }, propertyPath: structuredClone(this.propertyPath), }; if (this.cachedContent !== FieldRun.invalidContentIndicator) { json.cachedContent = this.cachedContent; } if (this.formatOptions) { json.formatOptions = structuredClone(this.formatOptions); } return json; } /** Create a deep copy of this FieldRun. */ clone() { return new FieldRun(this.toJSON()); } get isEmpty() { return this.stringify().length === 0; } /** Convert this FieldRun to a simple string representation. */ stringify() { return this.cachedContent; } /** Returns true if `this` is equivalent to `other`. */ equals(other) { if (!(other instanceof FieldRun) || !super.equals(other)) { return false; } if (this.propertyHost.elementId !== other.propertyHost.elementId || this.propertyHost.className !== other.propertyHost.className || this.propertyHost.schemaName !== other.propertyHost.schemaName) { return false; } if (this.propertyPath.propertyName !== other.propertyPath.propertyName) { return false; } const thisAccessors = this.propertyPath.accessors ?? []; const otherAccessors = other.propertyPath.accessors ?? []; if (thisAccessors.length !== otherAccessors.length) { return false; } if (!thisAccessors.every((value, index) => value === otherAccessors[index])) { return false; } if (this.formatOptions && other.formatOptions) { // We anticipate new formatting options being added in the future. // So to account for properties we don't know about, just compare the string representations. if (JSON.stringify(this.formatOptions) !== JSON.stringify(other.formatOptions)) { return false; } } else if (this.formatOptions || other.formatOptions) { return false; } return true; } } /** A collection of [[Run]]s and [[List]]s. Paragraphs can be appended to [[List]]s and [[TextBlock]]s. * Each paragraph is laid out on a separate line. If included in a [[List]], the paragraph will be treated as a list item. * @beta */ export class Paragraph extends TextBlockComponent { type = "paragraph"; children; constructor(props) { super(props); this.children = props?.children?.map((child) => child.type === "list" ? List.create(child) : Run.fromJSON(child)) ?? []; } /** Create a paragraph from its JSON representation. */ static create(props) { return new Paragraph(props); } clearStyleOverrides(options) { clearStyleOverrides(this, options); } get isEmpty() { return this.children.length === 0; } clone() { return new Paragraph(this.toJSON()); } toJSON() { return { ...super.toJSON(), children: this.children.map((child) => child.toJSON()), }; } /** Compute a string representation of this paragraph by concatenating the string representations of all of its children. */ stringify(options, context) { return this.children.map((x, index) => (index > 0 && x.type === "list") ? `${options?.paragraphBreak ?? " "}${x.stringify(options, context)}` : x.stringify(options, context)).join("") ?? ""; } equals(other) { return (other instanceof Paragraph) && super.equals(other); } } /** A collection of list items ([[Paragraph]]s). Lists can be appended to [[Paragraph]]s. * Lists will be laid out on a new line. Each item in a list is laid out on a separate line. * @beta */ export class List extends TextBlockComponent { type = "list"; children; constructor(props) { super(props); this.children = props?.children?.map((child) => Paragraph.create(child)) ?? []; } /** Create a list from its JSON representation. */ static create(props) { return new List({ ...props, type: "list" }); } clearStyleOverrides(options) { clearStyleOverrides(this, options); } get isEmpty() { return this.children.length === 0; } clone() { return new List(this.toJSON()); } toJSON() { return { ...super.toJSON(), type: "list", children: this.children.map((run) => run.toJSON()), }; } /** Compute a string representation of this list by concatenating the string representations of all of its [[children]]. */ stringify(options, context) { const children = this.children.map((x, index) => { const depth = context?.depth ?? 0; const marker = getMarkerText(this.styleOverrides.listMarker ?? TextStyleSettings.defaultProps.listMarker, index + 1); const tab = (options?.tabsAsSpaces ? " ".repeat(options.tabsAsSpaces) : "\t").repeat(depth); return `${tab}${marker}${options?.listMarkerBreak ?? " "}${x.stringify(options, { depth: depth + 1 })}`; }); return children.join(options?.paragraphBreak ?? " ") ?? ""; } equals(other) { return (other instanceof List) && super.equals(other); } } /** Represents a formatted text document consisting of a series of [[Paragraph]]s, each laid out on a separate line and containing their own content. * No word-wrapping is applied to the document unless a [[width]] greater than zero is specified. * @see [[TextAnnotation]] to position a text block as an annotation in 2d or 3d space. * @beta */ export class TextBlock extends TextBlockComponent { children; /** The width of the document in meters. Lines that would exceed this width are instead wrapped around to the next line if possible. * A value less than or equal to zero indicates no wrapping is to be applied. * Default: 0 */ width; constructor(props) { super(props); this.width = props.width ?? 0; this.children = props?.children?.map((para) => Paragraph.create(para)) ?? []; } clearStyleOverrides(options) { clearStyleOverrides(this, options); } toJSON() { return { ...super.toJSON(), width: this.width, children: this.children.map((x) => x.toJSON()), }; } /** Create a text block from its JSON representation. */ static create(props) { return new TextBlock(props ?? {}); } /** Returns true if every paragraph in this text block is empty. */ get isEmpty() { return !this.children || this.children.every((child) => child.isEmpty); } clone() { return new TextBlock(this.toJSON()); } /** Compute a string representation of the document's contents by concatenating the string representations of each of its [[children]], separated by [[TextBlockStringifyOptions.paragraphBreak]]. */ stringify(options) { return this.children.map((x) => x.stringify(options)).join(options?.paragraphBreak ?? " ") || ""; } /** Add and return a new paragraph. * By default, the paragraph will be created with no [[styleOverrides]], so that it inherits the style of this block. * @param seedFromLast If true and [[children]] is not empty, the new paragraph will inherit the style overrides of the last child in this block. * @note Be sure you pass in [[ParagraphProps]] and not [[Paragraph]] or style overrides will be ignored. */ appendParagraph(props, seedFromLast = false) { const seed = seedFromLast ? this.children[this.children.length - 1] : undefined; const styleOverrides = { ...seed?.styleOverrides, ...props?.styleOverrides }; const paragraph = Paragraph.create({ ...props, styleOverrides }); this.children.push(paragraph); return paragraph; } /** Append a run to the last [[Paragraph]] in this block. * If the block contains no [[children]], a new [[Paragraph]] will first be created using [[appendParagraph]]. */ appendRun(run) { const paragraph = this.children[this.children.length - 1] ?? this.appendParagraph(); paragraph.children.push(run); } equals(other) { if (!(other instanceof TextBlock)) { return false; } if (!super.equals(other)) { return false; } if (this.width !== other.width) { return false; } if (this.children && other.children) { if (this.children.length !== other.children.length) { return false; } return this.children.every((child, index) => other.children && child.equals(other.children[index])); } return true; } } /** * Recursively traverses a [[StructuralTextBlockComponent]] tree, yielding each child component along with its parent container. * This generator enables depth-first iteration over all components in a text block structure, including paragraphs, lists, and runs. * * @param parent The root container whose children should be traversed. * @returns An IterableIterator yielding objects with the child component and its parent container. * @beta */ export function* traverseTextBlockComponent(parent) { for (const child of parent.children) { yield { parent, child }; if (child.type === "list" || child.type === "paragraph") { yield* traverseTextBlockComponent(child); } } } /** * Returns the formatted marker text for a list item based on the marker type and item number. * Supports ordered and unordered list markers, including alphabetic, Roman numeral, and numeric formats. * @param marker The type of list marker to use. * @param num The item number in the list. * @returns The formatted marker string for the list item. * @beta */ export function getMarkerText(marker, num) { let markerString = ""; switch (marker.enumerator) { case undefined: case ListMarkerEnumerator.Number: markerString = `${num}`; break; case ListMarkerEnumerator.Letter: markerString = integerToAlpha(num); break; case ListMarkerEnumerator.RomanNumeral: markerString = integerToRoman(num); break; default: markerString = marker.enumerator; break; } if (marker.case) { markerString = marker.case === "upper" ? markerString.toUpperCase() : markerString.toLowerCase(); } const terminator = marker.terminator === "period" ? "." : marker.terminator === "parenthesis" ? ")" : ""; return `${markerString}${terminator}`; } /** * Converts an integer to its Roman numeral representation. * Supports numbers from 1 and above. * @param num The integer to convert. * @returns The Roman numeral string. */ function integerToRoman(num) { const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; const symbols = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; let roman = ''; for (let i = 0; i < values.length; i++) { while (num >= values[i]) { roman += symbols[i]; num -= values[i]; } } return roman; } /** * Converts an integer to its alphabetic representation (A-Z, AA-ZZ, etc.). * Used for ordered list markers with alphabetic styles. * @param num The integer to convert (1-based). * @returns The alphabetic string for the given number. */ function integerToAlpha(num) { const letterOffset = (num - 1) % 26; const letter = String.fromCharCode(65 + letterOffset); const depth = Math.ceil(num / 26); return letter.repeat(depth); } //# sourceMappingURL=TextBlock.js.map