import Row from './row.ts'; import { arrayInsert, maxAdd, minSub } from './fns.ts'; import Option, { None, Some } from './option.ts'; import { getRuntime, logDebug, logWarning } from './runtime.ts'; import { Position, SearchDirection } from './types.ts'; export class Document { #rows: Row[]; /** * Has the document been modified? */ public dirty: boolean; private constructor() { this.#rows = []; this.dirty = false; } public get numRows(): number { return this.#rows.length; } public static default(): Document { return new Document(); } public isEmpty(): boolean { return this.#rows.length === 0; } /** * Open a file for editing */ public async open(filename: string): Promise { const { file } = await getRuntime(); // Clear any existing rows if (!this.isEmpty()) { this.#rows = []; } const rawFile = await file.openFile(filename); rawFile.split(/\r?\n/) .forEach((row) => this.insertRow(this.numRows, row)); this.dirty = false; return this; } /** * Save the current document */ public async save(filename: string): Promise { const { file } = await getRuntime(); await file.saveFile(filename, this.rowsToString()); this.dirty = false; } public find( q: string, at: Position, direction: SearchDirection = SearchDirection.Forward, ): Option { if (at.y >= this.numRows) { logWarning('Trying to search beyond the end of the current file', { at, document: this, }); return None; } const position = Position.from(at); const start = (direction === SearchDirection.Forward) ? at.y : 0; const end = (direction === SearchDirection.Forward) ? this.numRows : maxAdd(at.y, 1, this.numRows); for (let y = start; y < end; y++) { if (this.row(position.y).isNone()) { return None; } const maybeMatch = this.#rows[y].find(q, position.x, direction); if (maybeMatch.isSome()) { position.x = maybeMatch.unwrap(); return Some(position); } if (direction === SearchDirection.Forward) { position.y = maxAdd(position.y, 1, this.numRows - 1); position.x = 0; } else { position.y = minSub(position.y, 1, 0); console.assert(position.y < this.numRows); position.x = this.#rows[position.y].size - 1; } } return None; } public insert(at: Position, c: string): void { if (at.y > this.numRows) { return; } this.dirty = true; if (at.y === this.numRows) { this.insertRow(this.numRows, c); } else { this.#rows[at.y].insertChar(at.x, c); this.#rows[at.y].update(None); } } /** * Insert a new line, splitting and/or creating a new row as needed */ public insertNewline(at: Position): void { if (at.y > this.numRows) { return; } this.dirty = true; // Just add a simple blank line if (at.y === this.numRows) { this.#rows.push(Row.default()); return; } // Split the current row, and insert a new // row with the leftovers const currentRow = this.#rows[at.y]; const newRow = currentRow.split(at.x); currentRow.update(None); newRow.update(None); this.#rows = arrayInsert(this.#rows, at.y + 1, newRow); } /** * Remove a character from the document, merging * adjacent lines if necessary */ public delete(at: Position): void { const len = this.numRows; if (at.y >= len) { return; } this.dirty = true; const maybeRow = this.row(at.y); if (maybeRow.isNone()) { return; } const row = maybeRow.unwrap(); const mergeNextRow = at.x === row.size && this.row(at.y + 1).isSome(); logDebug('Document.delete', { method: 'Document.delete', at, mergeNextRow, }); // If we are at the end of a line, and press delete, // add the contents of the next row, and delete // the merged row object (This also works for pressing // backspace at the beginning of a line: the cursor is // moved to the end of the previous line) if (mergeNextRow) { // At the end of a line, pressing delete will merge // the next line into the current one const rowToAppend = this.#rows[at.y + 1].toString(); row.append(rowToAppend); this.deleteRow(at.y + 1); } else { row.delete(at.x); } row.update(None); } public row(i: number): Option { if (i >= this.numRows || i < 0) { return None; } return Option.from(this.#rows.at(i)); } public insertRow(at: number = this.numRows, s: string = ''): void { this.#rows = arrayInsert(this.#rows, at, Row.from(s)); this.#rows[at].update(None); this.dirty = true; } public highlight(searchMatch: Option): void { this.#rows.forEach((row) => { row.update(searchMatch); }); } /** * Delete the specified row * @param at - the index of the row to delete */ protected deleteRow(at: number): void { this.#rows.splice(at, 1); } /** * Convert the array of row objects into one string */ protected rowsToString(): string { return this.#rows.map((r) => r.toString()).join('\n'); } } export default Document;