UNPKG

lazy-widgets

Version:

Typescript retained mode GUI for the HTML canvas API

1,015 lines 43 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { measureTextDims } from '../helpers/measureTextDims.js'; import { multiFlagField } from '../decorators/FlagFields.js'; import { DynMsg, Msg } from '../core/Strings.js'; const WIDTH_OVERRIDING_CHARS = new Set(['\n', '\t', ' ']); const ELLIPSIS = '...'; const SPACE_REGEX = /\s/; /** * The type of a {@link TextRenderGroup}. * * @category Helper */ export var TextRenderGroupType; (function (TextRenderGroupType) { TextRenderGroupType[TextRenderGroupType["Range"] = 0] = "Range"; TextRenderGroupType[TextRenderGroupType["Inline"] = 1] = "Inline"; })(TextRenderGroupType || (TextRenderGroupType = {})); /** * The mode to use for text wrapping in {@link TextHelper}. * * @category Helper */ export var WrapMode; (function (WrapMode) { /** No text wrapping. Text will overflow if it exceeds the maximum width. */ WrapMode["None"] = "none"; /** * No text wrapping, but instead of clipping the text, the end is replaced * with ellipsis. If the text won't even fit ellipsis, then the text is * clipped. */ WrapMode["Ellipsis"] = "ellipsis"; /** * Whitespaces always have width. The default wrapping mode for input * widgets */ WrapMode["Normal"] = "normal"; /** * Whitespaces at the end of a line which result in an overflow have no * width. The default wrapping mode for widgets that display text, since * spaces at the beginning of a line due to wrapping looks weird in * {@link Label | labels}. Whitespaces at the beginning of a new line are * still kept, as they are deliberate. */ WrapMode["Shrink"] = "shrink"; })(WrapMode || (WrapMode = {})); /** * The mode to use for text alignment in {@link TextHelper}. * * @category Helper */ export var TextAlignMode; (function (TextAlignMode) { /** Align to the start of the line. Equivalent to a ratio of 0. */ TextAlignMode[TextAlignMode["Start"] = 0] = "Start"; /** Align to the center of the line. Equivalent to a ratio of 0.5. */ TextAlignMode[TextAlignMode["Center"] = 0.5] = "Center"; /** Align to the end of the line. Equivalent to a ratio of 0.5. */ TextAlignMode[TextAlignMode["End"] = 1] = "End"; })(TextAlignMode || (TextAlignMode = {})); const CHARSET = '~!@#$%^&*()_+`1234567890-=qwertyuiop[]\\QWERTYUIOP{}|asdfghjkl;\'ASDFGHJKL:"zxcvbnm,./ZXCVBNM<>?'; /** * An aggregate helper class for widgets that contain text. * * Contains utilities for measuring text dimensions, converting between offsets * in pixels and text indices and painting. * * @category Helper */ export class TextHelper { constructor() { /** The current string of text. */ this.text = ''; /** The current font used for rendering text. */ this.font = ''; /** * The current maximum text width. If not Infinite and * {@link TextHelper#wrapMode} is not `WrapMode.None` and not * `WrapMode.Ellipsis`, then text will be wrapped and width will be set to * maxWidth. */ this.maxWidth = Infinity; /** * The height of each line of text when wrapped. If null, then the helper * will try to automatically detect it. */ this.lineHeight = null; /** * The amount of spacing between lines. If null, then the helper will try to * automatically detect it. */ this.lineSpacing = null; /** * The amount of spaces that each tab character is equivalent to. By * default, it is equivalent to 4 spaces. */ this.tabWidth = 4; /** The mode for text wrapping */ this.wrapMode = WrapMode.Normal; /** * The text alignment mode. Can also be a ratio. * * Note that this only aligns text in the text's width. If * {@link TextHelper#maxWidth} is infinite, then you may still need to align * the widget that uses this text helper with a {@link BaseContainer} * because the width will be set to the longest line range's width. */ this.alignMode = TextAlignMode.Start; /** The current largest text width. May be outdated. */ this._width = 0; /** The current total text height. May be outdated. */ this._height = 0; /** The current {@link TextHelper#lineHeight}. May be outdated */ this._lineHeight = 0; /** The current {@link TextHelper#lineSpacing}. May be outdated */ this._lineSpacing = 0; /** The actual {@link TextHelper#tabWidth} in pixels. May be outdated */ this._tabWidth = 0; /** The width of a space in pixels. May be outdated */ this._spaceWidth = 0; /** Does the text need to be re-measured? */ this.measureDirty = true; /** Does the line height or spacing need to be re-measured? */ this.lineHeightSpacingDirty = true; /** Do the space and tab widths need to be re-measured? */ this.tabWidthDirty = true; /** {@link TextHelper#dirty} but for internal use. */ this._dirty = false; /** See {@link TextHelper#lineRanges}. For internal use only. */ this._lineRanges = []; } /** * Has the text (or properties associated with it) changed? Resets to false * after reading the current value. */ get dirty() { const wasDirty = this._dirty; this.cleanDirtyFlag(); return wasDirty; } /** Resets {@link TextHelper#dirty} to false */ cleanDirtyFlag() { this._dirty = false; } /** * Measure a slice of text taking left offset into account. If left offset * is 0, then this will also add the left bounding box overhang. If not, * then it will just return the width. * * Only for slices of text which have no width-overriding characters, else, * you will get wrong measurements. * * @returns Returns the new horizontal offset */ measureTextSlice(left, start, end) { const metrics = measureTextDims(this.text.slice(start, end), this.font); if (left === 0) { return metrics.width + Math.max(0, metrics.actualBoundingBoxLeft); } else { return left + metrics.width; } } /** * Get width from line range start to index. Handles out of bounds indices, * but keeps them in the same line */ getLineRangeWidthUntil(range, index) { // If before or at first group's start index, 0 width if (index <= range[0].rangeStart) { return 0; } // Find text render group that this index belongs to let groupIndex = 0; for (; groupIndex < range.length; groupIndex++) { // If index is at this group's end, return group's right value. // Most width-overriding groups have a length of 1 and therefore // just stop here const group = range[groupIndex]; const groupEnd = group.rangeEnd; if (index == groupEnd) { return group.right; } else if (index >= group.rangeStart && index < groupEnd) { break; } } // If index was after line end, pick end of last group if (groupIndex === range.length) { return range[groupIndex - 1].right; } // Find left value let left = 0; if (groupIndex > 0) { left = range[groupIndex - 1].right; } // Measure the slice of text. Interpolate if it's a width-overidding // group const group = range[groupIndex]; if (group.overridesWidth) { return left + (group.right - left) * (index - group.rangeStart) / (group.rangeEnd - group.rangeStart); } else { return this.measureTextSlice(left, group.rangeStart, index); } } /** * Similar to {@link measureTextDims}, but uses text render groups for * optimisation purposes and for the ability of individual characters to * override their natively measured size; tabs having a dynamic size that * aligns them to multiples of a value and newlines having no length. * * @param start - The inclusive index to start measuring at. If there are render groups and unmeasured text before this index, then this value will be overridden to include the unmeasured text. Render groups will also be merged if they don't override width. * @param end - The exclusive index to stop measuring at. * @param lineRange - The current text render groups for this line of text. This will be updated in place. * @param maxWidth - The maximum width of a line of text. If the line contains a single character, this will be ignored. * @returns Returns true if the line range was modified and it fit into the maximum width */ measureText(start, end, maxWidth, lineRange) { var _a, _b; // Remove render groups that intersect the range that will be measured. // Removing a group means that the group will have to be re-measured and // therefore start is overridden let wantedGroups = 0; for (; wantedGroups < lineRange.length; wantedGroups++) { const group = lineRange[wantedGroups]; if (start >= group.rangeStart && start < group.rangeEnd) { start = group.rangeStart; break; } } // Correct start value; attempt to merge with previous groups or expand // the measurement to include previous parts of text that haven't been // measured yet if (wantedGroups > 0) { let lastGroup = lineRange[wantedGroups - 1]; if (lastGroup.rangeEnd !== start) { start = lastGroup.rangeEnd; if (--wantedGroups > 0) { lastGroup = lineRange[wantedGroups]; } else { lastGroup = null; } } if (lastGroup !== null && !lastGroup.overridesWidth && !WIDTH_OVERRIDING_CHARS.has(this.text[start])) { start = lastGroup.rangeStart; wantedGroups--; } } // Find left horizontal offset let left = 0; if (wantedGroups > 0) { left = lineRange[wantedGroups - 1].right; } // Measure range of text, potentially splitting it into render groups let groupStart = start; const addedGroups = []; while (groupStart < end) { if (this.text[groupStart] === '\t') { // Align to tab width const tabWidth = this.actualTabWidth; left = (Math.floor(left / tabWidth) + 1) * tabWidth; addedGroups.push({ type: TextRenderGroupType.Range, rangeStart: groupStart, rangeEnd: ++groupStart, right: left, overridesWidth: true, visible: false, }); } else if (this.text[groupStart] === '\n') { // Make it 0-width and ignore all other text addedGroups.push({ type: TextRenderGroupType.Range, rangeStart: groupStart, rangeEnd: ++groupStart, right: left, overridesWidth: true, visible: false, }); if (groupStart < end) { console.warn(Msg.ROGUE_NEWLINE); } break; } else if (this.text[groupStart] === ' ') { // Make single group that contains all spaces let groupEnd = groupStart + 1; for (; groupEnd < end && this.text[groupEnd] === ' '; groupEnd++) { /* empty */ } left += this.spaceWidth * (groupEnd - groupStart); addedGroups.push({ type: TextRenderGroupType.Range, rangeStart: groupStart, rangeEnd: groupEnd, right: left, overridesWidth: true, visible: false, }); groupStart = groupEnd; } else { // Find group end index; at next width-overriding character or // at end let nextNewline = this.text.indexOf('\n', groupStart + 1); if (nextNewline === -1) { nextNewline = Infinity; } let nextTab = this.text.indexOf('\t', groupStart + 1); if (nextTab === -1) { nextTab = Infinity; } let nextSpace = this.text.indexOf(' ', groupStart + 1); if (nextSpace === -1) { nextSpace = Infinity; } const groupEnd = Math.min(nextNewline, nextTab, nextSpace, end); // Measure group left = this.measureTextSlice(left, groupStart, groupEnd); addedGroups.push({ type: TextRenderGroupType.Range, rangeStart: groupStart, rangeEnd: groupEnd, right: left, overridesWidth: false, visible: true, }); groupStart = groupEnd; } } // Check if this fits in maximum width const groupCount = wantedGroups + addedGroups.length; const lastGroup = (_b = (_a = addedGroups[addedGroups.length - 1]) !== null && _a !== void 0 ? _a : lineRange[wantedGroups - 1]) !== null && _b !== void 0 ? _b : null; if (lastGroup === null) { // Lines ranges must have at least one group lineRange.length = 0; lineRange.push({ type: TextRenderGroupType.Range, rangeStart: start, rangeEnd: start, right: 0, overridesWidth: false, visible: false, }); return true; } else if ((groupCount === 1 && (lastGroup.rangeEnd - lastGroup.rangeStart) <= 1) || lastGroup.right <= maxWidth) { lineRange.length = wantedGroups; lineRange.push(...addedGroups); return true; } else { return false; } } /** * Update {@link TextHelper#_width}, {@link TextHelper#_lineHeight} and * {@link TextHelper#_lineSpacing}. Sets {@link TextHelper#measureDirty} to * false. Does nothing if measurement is not needed. */ updateTextDims() { var _a; // Update line height or line spacing if needed if (this.lineHeightSpacingDirty) { this.lineHeightSpacingDirty = false; const oldLineHeight = this._lineHeight; const oldLineSpacing = this._lineSpacing; if (this.lineHeight === null || this.lineSpacing === null) { const metrics = measureTextDims(CHARSET, this.font); if (this.lineHeight === null) { const fontAscent = metrics.fontBoundingBoxAscent; if (fontAscent === undefined) { // HACK fallback for browsers that don't support this // yet this._lineHeight = Math.max(metrics.actualBoundingBoxAscent, measureTextDims('M', this.font).actualBoundingBoxAscent + metrics.actualBoundingBoxDescent); } else { this._lineHeight = fontAscent; } } else { this._lineHeight = this.lineHeight; } if (this.lineSpacing === null) { this._lineSpacing = (_a = metrics.fontBoundingBoxDescent) !== null && _a !== void 0 ? _a : metrics.actualBoundingBoxDescent; } else { this._lineSpacing = this.lineSpacing; } } else { this._lineHeight = this.lineHeight; this._lineSpacing = this.lineSpacing; } // If line height or spacing changed, text needs to be re-measured if (oldLineHeight !== this._lineHeight || oldLineSpacing !== this._lineSpacing) { this.measureDirty = true; } } // Update tab width if needed if (this.tabWidthDirty) { this.tabWidthDirty = false; this._spaceWidth = measureTextDims(' ', this.font).width; this._tabWidth = this._spaceWidth * this.tabWidth; } // Abort if measurement not needed if (!this.measureDirty) { return; } // Mark as clean this.measureDirty = false; const fullLineHeight = this._lineHeight + this._lineSpacing; const notWrapping = this.maxWidth === Infinity || this.wrapMode === WrapMode.None || this.wrapMode === WrapMode.Ellipsis; if (this.text.length === 0) { // Special case for empty string; set height to height of single // line and width to 0 if maxWidth is not set or maxWidth if set and // wrap mode is not None this._height = fullLineHeight; this._width = notWrapping ? 0 : this.maxWidth; this._lineRanges.length = 1; this._lineRanges[0] = [{ type: TextRenderGroupType.Range, rangeStart: 0, rangeEnd: 0, right: 0, overridesWidth: false, visible: false, }]; } else if (notWrapping) { // Don't wrap text, but split lines when there's a newline character this._lineRanges.length = 0; let lineStart = 0; this._height = 0; this._width = 0; const text = this.text; // eslint-disable-next-line no-constant-condition while (true) { // Where is the next newline? const newline = this.text.indexOf('\n', lineStart); const atEnd = newline === -1; const end = atEnd ? text.length : (newline + 1); // Measure this block of text and add it to the line ranges const range = []; this.measureText(lineStart, end, Infinity, range); this._lineRanges.push(range); this._height += fullLineHeight; const width = range[range.length - 1].right; if (width > this._width) { this._width = width; } // At end, abort if (atEnd) { break; } // Set start of next line lineStart = end; } } else { // Wrap text this._lineRanges.length = 0; let range = []; const text = this.text; let wordStart = -1; for (let i = 0; i <= text.length;) { const isSpace = SPACE_REGEX.test(text[i]); const atEnd = i === text.length; // If this is a whitespace, wrap the previous word and check // where this character fits if (isSpace || atEnd) { // Try fitting word if any if (wordStart >= 0 && !this.measureText(wordStart, i, this.maxWidth, range)) { // Overflow, check if word fits in new line const newRange = []; if (this.measureText(wordStart, i, this.maxWidth, newRange)) { // Fits in new line. Push old line to line ranges if // it had any text render groups if (range.length === 0) { throw new Error(Msg.EMPTY_LINE_RANGE); } this._lineRanges.push(range); range = newRange; } else { // Doesn't fit in new line. Fit as much as possible // in current line and move rest to new line by // backtracking to where the split occurs. Don't // reverse this loop; although it may seem more // efficient, it breaks when the word is broken // across more than 2 lines let j = wordStart; for (; j < i - 1; j++) { if (!this.measureText(j, j + 1, this.maxWidth, range)) { break; } } this._lineRanges.push(range); range = newRange; i = j; wordStart = j; continue; } } wordStart = -1; // End line if (atEnd) { // If there isn't a render group in the line range yet, // add it. Use last group's position. If there isn't a // last group, default to the very beginning if (range.length === 0) { const lastLineRange = this._lineRanges[this._lineRanges.length - 1]; if (lastLineRange === undefined) { range.push({ type: TextRenderGroupType.Range, rangeStart: 0, rangeEnd: 0, right: 0, overridesWidth: false, visible: false, }); } else { const lastGroup = lastLineRange[lastLineRange.length - 1]; if (lastGroup === undefined) { range.push({ type: TextRenderGroupType.Range, rangeStart: 0, rangeEnd: 0, right: 0, overridesWidth: false, visible: false, }); } else { range.push({ type: TextRenderGroupType.Range, rangeStart: lastGroup.rangeEnd, rangeEnd: lastGroup.rangeEnd, right: 0, overridesWidth: false, visible: false, }); } } } this._lineRanges.push(range); break; } // Try fitting whitespace character if (text[i] === '\n') { // Newline character. Break line, but measure text // anyways to update line range this.measureText(i, i + 1, Infinity, range); this._lineRanges.push(range); range = []; } else if (!this.measureText(i, i + 1, this.maxWidth, range)) { // Regular whitespace character overflow: put whitespace // in next line but measure it anyways to update line // range. If in the shrink wrap mode, then group up as // many whitespaces as possible and make a zero-width // group out of them if (this.wrapMode === WrapMode.Shrink) { const spaceGroupStart = i; do { i++; } while (text[i] !== '\n' && SPACE_REGEX.test(text[i])); const lastGroup = range[range.length - 1]; range.push({ type: TextRenderGroupType.Range, rangeStart: spaceGroupStart, rangeEnd: i, right: lastGroup !== undefined ? lastGroup.right : 0, overridesWidth: true, visible: false, }); this._lineRanges.push(range); range = []; continue; } else if (this.wrapMode === WrapMode.Normal) { this._lineRanges.push(range); range = []; this.measureText(i, i + 1, Infinity, range); } else { throw new Error(DynMsg.INVALID_ENUM(this.wrapMode, 'WrapMode', 'wrapMode', true)); } } } else if (wordStart === -1) { wordStart = i; } // Incrementing down here so that we don't have to do i = j - 1 // when splitting words i++; } // Calculate dimensions this._width = this.maxWidth; this._height = fullLineHeight * this._lineRanges.length; } // handle horizontal overflow let actualMaxWidth = this.maxWidth; let hasEllipsis = false; if (this.wrapMode === WrapMode.Ellipsis) { const ellipsisWidth = measureTextDims(ELLIPSIS, this.font).width; // only add ellipsis if they fit, otherwise just clip if (ellipsisWidth <= this.maxWidth) { hasEllipsis = true; actualMaxWidth -= ellipsisWidth; } } for (const line of this._lineRanges) { const lastGroup = line[line.length - 1]; if (lastGroup.right <= this.maxWidth) { continue; } // line overflows, clip it let left = 0; for (const group of line) { const origRight = group.right; const completelyHidden = group.right <= left; if (completelyHidden || group.right > actualMaxWidth) { // already past max width or group intersects with // point of max width, clip it group.right = actualMaxWidth; group.overridesWidth = true; if (completelyHidden) { group.visible = false; } } left = origRight; } if (hasEllipsis) { line.push({ type: TextRenderGroupType.Inline, rangeStart: lastGroup.rangeEnd, rangeEnd: lastGroup.rangeEnd, right: this.maxWidth, overridesWidth: false, visible: true, text: ELLIPSIS, }); } } } /** * Paint a single text render group with a given offset and left value for * checking if the group is zero-width. left value must not be shifted. * * Used mainly for injecting debug code; you won't get much use out of this * method unless you have a very specific need. */ paintGroup(ctx, group, left, x, y) { if (!group.visible || group.right <= left) { // invisible or zero-width text group, don't bother rendering it return; } if (group.overridesWidth) { // width-overriding groups have an unsafe width for rendering, so we // have to clip them ctx.save(); ctx.beginPath(); ctx.rect(x, y - this._lineHeight, group.right - left, this.fullLineHeight); ctx.clip(); } if (group.type === TextRenderGroupType.Inline) { ctx.fillText(group.text, x, y); } else { ctx.fillText(this.text.slice(group.rangeStart, group.rangeEnd), x, y); } if (group.overridesWidth) { ctx.restore(); } } /** Paint all line ranges. */ paint(ctx, fillStyle, x, y) { // Apply fill style and font ctx.save(); ctx.font = this.font; ctx.fillStyle = fillStyle; ctx.textBaseline = 'alphabetic'; // Update line ranges if needed this.updateTextDims(); // Paint line (or lines) of text const fullLineHeight = this.fullLineHeight; let yOffset = y + this._lineHeight; for (let line = 0; line < this._lineRanges.length; line++) { let left = 0; const shift = this.getLineShift(line); for (const group of this._lineRanges[line]) { this.paintGroup(ctx, group, left, x + left + shift, yOffset); left = group.right; } yOffset += fullLineHeight; } // Restore text style ctx.restore(); } /** * Get the horizontal offset, in pixels, of the beginning of a character at * a given index. * * See {@link TextHelper#findIndexOffsetFromOffset} for the opposite. * * @param preferLineEnd - If true, the offset at the end of the line will be returned, instead of at the beginning of the line. Only applies to line ranges that were wrapped. * @returns Returns a 2-tuple containing the offset, in pixels. Vertical offset in the tuple is at the top of the character. Note that this is not neccessarily an integer. */ findOffsetFromIndex(index, preferLineEnd = false) { // Update line ranges if needed this.updateTextDims(); // If index is 0, an invalid negative number or there are no lines, it // is at the beginning if (index <= 0 || this._lineRanges.length === 0) { return [this.getLineShift(0), 0]; } // Check which line the index is in let line = 0; for (const range of this._lineRanges) { if (index < range[range.length - 1].rangeEnd) { break; } line++; } // Special case; the index is after the end, pick the end of the text if (line >= this._lineRanges.length) { line = this._lineRanges.length - 1; index = this.text.length; } // Handle wrapping preferences let lineRange = this._lineRanges[line]; if (preferLineEnd && line > 0 && index === lineRange[0].rangeStart) { // check if there is a newline at the end of the last render group // of the previous line const prevLineRange = this._lineRanges[line - 1]; const prevTextGroup = prevLineRange[prevLineRange.length - 1]; const prevEndIndex = prevTextGroup.rangeEnd - 1; if (prevEndIndex >= 0 && this.text[prevEndIndex] !== '\n') { // line was created due to wrap. prefer previous line line--; lineRange = prevLineRange; } } // Get horizontal offset return [ this.getLineRangeWidthUntil(lineRange, index) + this.getLineShift(line), line * this.fullLineHeight, ]; } /** * Get the index and horizontal offset, in pixels, of the beginning of a * character at a given offset. * * See {@link TextHelper#findOffsetFromIndex} for the opposite. * * @returns Returns a 2-tuple containing the index of the character at the offset and a 2-tuple containing the offset, in pixels. Note that this is not neccessarily an integer. Note that the returned offset is not the same as the input offset. The returned offset is exactly at the beginning of the character. This is useful for implementing selectable text. */ findIndexOffsetFromOffset(offset) { // If offset is before or at first character, text is empty or there are // no lines, default to index 0 const fullLineHeight = this.fullLineHeight; const firstShift = this.getLineShift(0); if (this.text === '' || (offset[0] <= firstShift && offset[1] < fullLineHeight) || offset[1] < 0) { return [0, [firstShift, 0]]; } // Find line being selected const line = Math.floor(offset[1] / fullLineHeight); // Update line ranges if needed this.updateTextDims(); // If this is beyond the last line, pick the last character if (line >= this._lineRanges.length) { const index = this.text.length; return [index, this.findOffsetFromIndex(index)]; } // If this is an empty line, stop const yOffset = line * fullLineHeight; const range = this._lineRanges[line]; const shift = this.getLineShift(line); const lineStart = range[0].rangeStart; if (range.length === 1 && lineStart === range[0].rangeEnd) { return [lineStart, [range[0].right + shift, yOffset]]; } // Special case; if this is at or before the start of the line, select // the beginning of the line if (offset[0] <= shift) { return [lineStart, [shift, yOffset]]; } // Special case; if line range ends with a newline, ignore last // character let lineEnd = range[range.length - 1].rangeEnd; if (this.text[lineEnd - 1] === '\n') { lineEnd--; } // For each character, find index at which offset is smaller than // total length minus half length of current character let closestNext = -1; let closestNextLen = 0; const xOffsetUnshifted = offset[0] - shift; let start = lineStart, end = lineEnd + 1, iLast = 0, lenLast = 0; do { // Measure length from the line start to the end of the current // character const i = start + Math.floor((end - start) / 2); const len = this.getLineRangeWidthUntil(range, i); if (len >= xOffsetUnshifted) { end = i; closestNext = i; closestNextLen = len; } else { start = i + 1; iLast = i; lenLast = len; } } while (start !== end); // If cursor is after halfway point of character, use next character // instead if (iLast < lineEnd) { const iNext = iLast + 1; let lenNext = closestNextLen; if (closestNext !== iNext) { lenNext = this.getLineRangeWidthUntil(range, iNext); } const mid = lenLast + (lenNext - lenLast) / 2; if (xOffsetUnshifted >= mid) { return [iNext, [lenNext + shift, yOffset]]; } } return [iLast, [lenLast + shift, yOffset]]; } /** * Get a line number from a given cursor index. If out of bounds, returns * nearest in-bounds line. Line numbers start at 0. */ getLine(index) { if (index <= 0) { return 0; } // Update line ranges if needed this.updateTextDims(); for (let line = 0; line < this._lineRanges.length; line++) { const lineRange = this._lineRanges[line]; const lastGroup = lineRange[lineRange.length - 1]; if (index < lastGroup.rangeEnd) { return line; } } return this._lineRanges.length - 1; } /** * Get the index of the start of a line. If out of bounds, returns the * nearest in-bounds index */ getLineStart(line) { if (line <= 0) { return 0; } // Update line ranges if needed this.updateTextDims(); if (line >= this._lineRanges.length) { const lastLine = this._lineRanges[this._lineRanges.length - 1]; return lastLine[lastLine.length - 1].rangeEnd; } return this._lineRanges[line][0].rangeStart; } /** * Get the index of the end of a line. * * @param includeNewlines - If false, newline characters will be ignored and the end will be at their index, instead of after their index */ getLineEnd(line, includeNewlines = true) { if (line < 0) { return 0; } // Update line ranges if needed this.updateTextDims(); if (line >= this._lineRanges.length) { const lastLine = this._lineRanges[this._lineRanges.length - 1]; return lastLine[lastLine.length - 1].rangeEnd; } const lineRange = this._lineRanges[line]; const lastGroup = lineRange[lineRange.length - 1]; const lastIndex = lastGroup.rangeEnd; if (!includeNewlines && lastIndex > 0 && this.text[lastIndex - 1] === '\n' && lastGroup.rangeStart !== lastGroup.rangeEnd) { return lastIndex - 1; } else { return lastIndex; } } /** * Get the horizontal offset, in pixels, of the start of a line. Takes text * wrapping into account. Line indices before first line will be treated as * the first line, after the last line will be treated as a new empty line. */ getLineShift(line) { // No need to do any logic if aligned to the start const ratio = this.alignMode; if (ratio === 0) { return 0; } // Update line ranges if needed this.updateTextDims(); let referenceWidth = this.maxWidth; if (!isFinite(referenceWidth)) { referenceWidth = this.width; } if (line < 0) { line = 0; } else if (line >= this._lineRanges.length) { return referenceWidth * ratio; } const lineRange = this._lineRanges[line]; return (referenceWidth - lineRange[lineRange.length - 1].right) * ratio; } /** The current text width. Re-measures text if neccessary. */ get width() { this.updateTextDims(); return this._width; } /** The current total text height. Re-measures text if neccessary. */ get height() { this.updateTextDims(); return this._height; } /** * Which range of text indices are used for each line. * * If there is no text wrapping (`maxWidth` is `Infinity`, or `wrapMode` is * `WrapMode.None` or `wrapMode` is `WrapMode.Ellipsis`), then this will * contain a single tuple containing `[0, (text length)]`. * * If there is text wrapping, then this will be an array where each member * is a tuple containing the starting index of a line of text and the ending * index (exclusive) of a line of text. */ get lineRanges() { this.updateTextDims(); return [...this._lineRanges]; } /** * Get the current line height, even if {@link TextHelper#lineHeight} is * null. Re-measures line height if neccessary. */ get actualLineHeight() { this.updateTextDims(); return this._lineHeight; } /** * Get the current line spacing, even if {@link TextHelper#lineSpacing} is * null. Re-measures line spacing if neccessary. */ get actualLineSpacing() { this.updateTextDims(); return this._lineSpacing; } /** Get the current tab width in pixels. Re-measures if neccessary */ get actualTabWidth() { this.updateTextDims(); return this._tabWidth; } /** Get the current space width in pixels. Re-measures if necessary */ get spaceWidth() { this.updateTextDims(); return this._spaceWidth; } /** * Get the height between the start of each line; the full line height. * * Equivalent to the sum of {@link TextHelper#actualLineHeight} and * {@link TextHelper#actualLineSpacing} */ get fullLineHeight() { this.updateTextDims(); return this._lineHeight + this._lineSpacing; } } __decorate([ multiFlagField(['_dirty', 'measureDirty']) ], TextHelper.prototype, "text", void 0); __decorate([ multiFlagField(['_dirty', 'measureDirty', 'lineHeightSpacingDirty', 'tabWidthDirty']) ], TextHelper.prototype, "font", void 0); __decorate([ multiFlagField(['_dirty', 'measureDirty']) ], TextHelper.prototype, "maxWidth", void 0); __decorate([ multiFlagField(['_dirty', 'measureDirty', 'lineHeightSpacingDirty']) ], TextHelper.prototype, "lineHeight", void 0); __decorate([ multiFlagField(['_dirty', 'measureDirty', 'lineHeightSpacingDirty']) ], TextHelper.prototype, "lineSpacing", void 0); __decorate([ multiFlagField(['_dirty', 'measureDirty', 'tabWidthDirty']) ], TextHelper.prototype, "tabWidth", void 0); __decorate([ multiFlagField(['_dirty', 'measureDirty']) ], TextHelper.prototype, "wrapMode", void 0); __decorate([ multiFlagField(['_dirty']) ], TextHelper.prototype, "alignMode", void 0); //# sourceMappingURL=TextHelper.js.map