/** * Provides a stateful means of managing placeholders in text. * * Placeholders are numbers prefixed with the `$` character (e.g. `$1`). * Each number value represents the order in which a placeholder should * receive focus if multiple placeholders exist. * * Example scenario given `sum($3 offset $1) by($2)`: * 1. `sum( offset |) by()` * 2. `sum( offset 1h) by(|)` * 3. `sum(| offset 1h) by (label)` */ export default class PlaceholdersBuffer { private nextMoveOffset: number; private orders: number[]; private parts: string[]; constructor(text: string) { const result = this.parse(text); const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0; this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex); this.orders = result.orders; this.parts = result.parts; } clearPlaceholders() { this.nextMoveOffset = 0; this.orders = []; } getNextMoveOffset(): number { return this.nextMoveOffset; } hasPlaceholders(): boolean { return this.orders.length > 0; } setNextPlaceholderValue(value: string) { if (this.orders.length === 0) { return; } const currentPlaceholderIndex = this.orders[0]; this.parts[currentPlaceholderIndex] = value; this.orders = this.orders.slice(1); if (this.orders.length === 0) { this.nextMoveOffset = 0; return; } const nextPlaceholderIndex = this.orders[0]; // Case should never happen but handle it gracefully in case if (currentPlaceholderIndex === nextPlaceholderIndex) { this.nextMoveOffset = 0; return; } const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex; const indices = backwardMove ? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 } : { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex }; this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end); } toString(): string { return this.parts.join(''); } private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) { return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0); } private parse(text: string): ParseResult { const placeholderRegExp = /\$(\d+)/g; const parts = []; const orders = []; let textOffset = 0; while (true) { const match = placeholderRegExp.exec(text); if (!match) { break; } const part = text.slice(textOffset, match.index); parts.push(part); // Accounts for placeholders at text boundaries if (part !== '') { parts.push(''); } const order = parseInt(match[1], 10); orders.push({ index: parts.length - 1, order }); textOffset += part.length + match.length; } // Ensures string serialization still works if no placeholders were parsed // and also accounts for the remainder of text with placeholders parts.push(text.slice(textOffset)); return { // Placeholder values do not necessarily appear sequentially so sort the // indices to traverse in priority order orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index), parts, }; } } type ParseResult = { /** * Indices to placeholder items in `parts` in traversal order. */ orders: number[]; /** * Parts comprising the original text with placeholders occupying distinct items. */ parts: string[]; };