import Row from './row.ts'; import { arrayInsert, strlen } from './fns.ts'; import { HighlightType } from './highlight.ts'; import Option, { None, Some } from './option.ts'; import { getRuntime } from './runtime.ts'; import { Position } from './types.ts'; import { Search } from './search.ts'; export class Document { #rows: Row[]; #search: Search; /** * Has the document been modified? */ public dirty: boolean; private constructor() { this.#rows = []; this.#search = new Search(); this.dirty = false; } get numRows(): number { return this.#rows.length; } public static default(): Document { const self = new Document(); self.#search.parent = Some(self); return self; } 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 resetFind(): void { this.#search = new Search(); this.#search.parent = Some(this); } public find( q: string, key: string, ): Option { const possible = this.#search.search(q, key); if (possible.isSome()) { const potential = possible.unwrap(); // Update highlight of search match const row = this.#rows[potential.y]; // Okay, we have to take the Javascript string index (potential.x), convert // it to the Row 'character' index, and then convert that to the Row render index // so that the highlighted color starts in the right place. const start = row.cxToRx(row.byteIndexToCharIndex(potential.x)); // Just to be safe with unicode searches, take the number of 'characters' // as the search query length, not the JS string length. const end = start + strlen(q); for (let i = start; i < end; i++) { row.hl[i] = HighlightType.Match; } } return possible; } public insert(at: Position, c: string): void { 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); } this.dirty = true; } /** * Insert a new line, splitting and/or creating a new row as needed */ public insertNewline(at: Position): void { if (at.y > this.numRows) { return; } // Just add a simple blank line if (at.y === this.numRows) { this.#rows.push(Row.default()); this.dirty = true; return; } // Split the current row, and insert a new // row with the leftovers const newRow = this.#rows[at.y].split(at.x); newRow.update(None); this.#rows = arrayInsert(this.#rows, at.y + 1, newRow); this.dirty = true; } /** * 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; } const maybeRow = this.row(at.y); if (maybeRow.isNone()) { return; } const row = maybeRow.unwrap(); const mergeNextRow = at.x === row.size && at.y + 1 < len; const mergeIntoPrevRow = at.x === 0 && at.y > 0; // 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 if (mergeNextRow) { // At the end of a line, pressing delete will merge // the next line into the current on const rowToAppend = this.#rows.at(at.y + 1)!.toString(); row.append(rowToAppend); this.deleteRow(at.y + 1); } else if (mergeIntoPrevRow) { // At the beginning of a line, merge the current line // into the previous Row const rowToAppend = row.toString(); this.#rows[at.y - 1].append(rowToAppend); this.deleteRow(at.y); } else { row.delete(at.x); } row.update(None); this.dirty = true; } public row(i: number): Option { return Option.from(this.#rows[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 * @private */ private deleteRow(at: number): void { this.#rows.splice(at, 1); } /** * Convert the array of row objects into one string * @private */ private rowsToString(): string { return this.#rows.map((r) => r.toString()).join('\n'); } } export default Document;