tuil
ReferencePackages@mwillbanks/tuil-editorAPI

TextBufferSession

class exported by @mwillbanks/tuil-editor.

View rawEdit

class

Public class exported by @mwillbanks/tuil-editor.

export class TextBufferSession implements EditorSession {
  readonly #id: string;
  readonly #type: string;
  readonly #readOnly: boolean;
  readonly #masked: boolean;
  readonly #clipboard?: EditorProviderOptions["clipboard"];
  readonly #onDocumentChange?: EditorProviderOptions["onDocumentChange"];
  readonly #observers = new Set<(snapshot: EditorSnapshot) => void>();
  readonly #undo: HistoryEntry[] = [];
  readonly #redo: HistoryEntry[] = [];
  #text: string;
  #version = 0;
  #selections: readonly EditorSelection[];
  #decorations: readonly EditorDecoration[] = [];
  #diagnostics: readonly EditorDiagnostic[] = [];
  #mode: string;
  #viewportAnchor?: EditorPosition;
  #disposed = false;

  constructor(options: EditorProviderOptions = {}) {
    this.#id = options.id ?? "document";
    this.#type = options.documentType ?? "text/plain";
    this.#text = options.value ?? "";
    this.#readOnly = options.readOnly ?? false;
    this.#masked = options.masked ?? false;
    this.#clipboard = options.clipboard;
    this.#onDocumentChange = options.onDocumentChange;
    this.#mode = options.mode ?? "insert";
    this.#viewportAnchor = options.viewportAnchor;
    this.#selections = [selection(position(0, 0))];
  }

  snapshot(): EditorSnapshot {
    return Object.freeze({
      document: Object.freeze({
        id: this.#id,
        type: this.#type,
        version: this.#version,
        text: this.#exportText(),
      }),
      selections: this.#selections,
      decorations: this.#decorations,
      diagnostics: this.#diagnostics,
      mode: this.#mode,
      readOnly: this.#readOnly,
      canUndo: this.#undo.length > 0,
      canRedo: this.#redo.length > 0,
      viewportAnchor: this.#viewportAnchor,
    });
  }

  dispatch(transaction: EditorTransaction): EditorSnapshot {
    this.#assertActive();
    const changes = [...(transaction.changes ?? [])];
    if (changes.length > 0 && this.#readOnly) {
      throw new Error("Editor session is read-only");
    }
    this.#recordTransactionHistory(changes, transaction.addToHistory);
    const edits = prepareEdits(this.#text, changes);
    this.#text = applyEdits(this.#text, edits);
    this.#applyTransactionSelection(edits, transaction.selections);
    if (changes.length > 0 || transaction.selections) this.#version += 1;
    if (changes.length > 0) this.#onDocumentChange?.(this.#text);
    this.#notify();
    return this.snapshot();
  }

  #recordTransactionHistory(
    changes: readonly EditorChange[],
    addToHistory: boolean | undefined,
  ): void {
    if (changes.length === 0 || addToHistory === false) return;
    this.#undo.push({ text: this.#text, selections: this.#selections });
    this.#redo.length = 0;
  }

  #applyTransactionSelection(
    edits: readonly PreparedEdit[],
    selections: readonly EditorSelection[] | undefined,
  ): void {
    if (selections) {
      this.#selections = freezeSelections(this.#text, selections);
      return;
    }
    if (edits.length === 0) return;
    this.#selections = edits
      .toSorted((left, right) => left.index - right.index)
      .map((edit) => {
        const precedingDelta = edits
          .filter((candidate) => candidate.offsets[0] < edit.offsets[0])
          .reduce(
            (total, candidate) =>
              total +
              candidate.change.insert.length -
              (candidate.offsets[1] - candidate.offsets[0]),
            0,
          );
        return selection(
          positionAt(
            this.#text,
            edit.offsets[0] + edit.change.insert.length + precedingDelta,
          ),
        );
      });
  }

  execute(
    command: string | EditorCommand,
    argument?: unknown,
  ): boolean | Promise<boolean> {
    this.#assertActive();
    if (typeof command !== "string") return command.execute(this, argument);
    const clipboard = executeClipboardCommand(this, command, argument);
    if (clipboard !== undefined) return clipboard;
    return this.#executeSelectionCommand(command);
  }

  #executeSelectionCommand(command: string): boolean {
    if (command === "select-all") {
      this.#selections = [
        selection(position(0, 0), positionAt(this.#text, this.#text.length)),
      ];
    } else if (command === "delete-selection") {
      this.dispatch({
        changes: this.#selections.map((range) => ({ range, insert: "" })),
      });
      return true;
    } else if (command === "cursor-start") {
      this.dispatch({
        selections: [selection(position(0, 0))],
        addToHistory: false,
      });
      return true;
    } else if (command === "cursor-end") {
      this.dispatch({
        selections: [selection(positionAt(this.#text, this.#text.length))],
        addToHistory: false,
      });
      return true;
    } else {
      return false;
    }
    this.#notify();
    return true;
  }

  copy(): string | Promise<string> {
    this.#assertActive();
    const copied = this.#selections
      .map((range) => {
        const [start, end] = offsets(this.#text, range);
        const value = this.#text.slice(start, end);
        return this.#masked ? maskText(value) : value;
      })
      .join("\n");
    if (!this.#clipboard) return copied;
    return Promise.resolve(this.#clipboard.write(copied)).then(() => copied);
  }

  cut(): string | Promise<string> {
    return cutSelections(this, this.copy());
  }

  paste(value?: string): boolean | Promise<boolean> {
    return pasteSelections(this, this.#clipboard, value);
  }

  undo(): boolean {
    return this.#restoreHistory(this.#undo, this.#redo);
  }

  redo(): boolean {
    return this.#restoreHistory(this.#redo, this.#undo);
  }

  #restoreHistory(
    source: HistoryEntry[],
    destination: HistoryEntry[],
  ): boolean {
    this.#assertActive();
    const entry = source.pop();
    if (!entry) return false;
    destination.push({ text: this.#text, selections: this.#selections });
    this.#text = entry.text;
    this.#selections = entry.selections;
    this.#version += 1;
    this.#onDocumentChange?.(this.#text);
    this.#notify();
    return true;
  }

  search(query: string | RegExp): readonly EditorRange[] {
    this.#assertActive();
    if (typeof query === "string" && query.length === 0) return [];
    const expression = createGlobalSearchExpression(query);
    const ranges: EditorRange[] = [];
    for (const match of this.#text.matchAll(expression)) {
      ranges.push(
        Object.freeze({
          anchor: positionAt(this.#text, match.index),
          head: positionAt(this.#text, match.index + match[0].length),
        }),
      );
      if (match[0].length === 0) expression.lastIndex += 1;
    }
    return Object.freeze(ranges);
  }

  replace(query: string | RegExp, replacement: string, all = true): number {
    const ranges = this.search(query);
    const selected = all ? ranges : ranges.slice(0, 1);
    if (selected.length === 0) return 0;
    this.dispatch({
      changes: selected.map((range) => ({
        range,
        insert: replacement,
      })),
    });
    return selected.length;
  }

  serialize(format: "text" | "json" | "markdown" = "text"): string {
    const text = this.#exportText();
    return format === "json"
      ? JSON.stringify(
          this.#masked
            ? { type: this.#type, text, masked: true }
            : { type: this.#type, text },
        )
      : text;
  }

  #exportText(): string {
    return this.#masked ? maskText(this.#text) : this.#text;
  }

  subscribe(observer: (snapshot: EditorSnapshot) => void): () => void {
    this.#assertActive();
    this.#observers.add(observer);
    return () => this.#observers.delete(observer);
  }

  setDecorations(decorations: readonly EditorDecoration[]): void {
    this.#decorations = Object.freeze([...decorations]);
    this.#notify();
  }

  setDiagnostics(diagnostics: readonly EditorDiagnostic[]): void {
    this.#diagnostics = Object.freeze([...diagnostics]);
    this.#notify();
  }

  setMode(mode: string): void {
    this.#mode = mode;
    this.#notify();
  }

  setViewportAnchor(anchor: EditorPosition): void {
    this.#viewportAnchor = clamp(this.#text, anchor);
    this.#notify();
  }

  dispose(): void {
    this.#disposed = true;
    this.#observers.clear();
    this.#undo.length = 0;
    this.#redo.length = 0;
  }

  #notify(): void {
    const snapshot = this.snapshot();
    for (const observer of this.#observers) observer(snapshot);
  }

  #assertActive(): void {
    if (this.#disposed) throw new Error("Editor session is disposed");
  }
}

Members

MemberTypeRequiredDescriptionRelated types
#idstringYesThe #id member uses the string contract.
#typestringYesThe #type member uses the string contract.
#readOnlybooleanYesThe #readOnly member uses the boolean contract.
#maskedbooleanYesThe #masked member uses the boolean contract.
#clipboardimport("./index").EditorClipboardAdapter | undefinedNoThe #clipboard member uses the import("./index").EditorClipboardAdapter | undefined contract.
#onDocumentChange((value: string) => void) | undefinedNoThe #onDocumentChange member uses the ((value: string) => void) | undefined contract.
#observersSet<(snapshot: EditorSnapshot) => void>YesThe #observers member uses the Set<(snapshot: EditorSnapshot) => void> contract.EditorSnapshot
#undoHistoryEntry[]YesThe #undo member uses the HistoryEntry[] contract.
#redoHistoryEntry[]YesThe #redo member uses the HistoryEntry[] contract.
#textstringYesThe #text member uses the string contract.
#versionnumberYesThe #version member uses the number contract.
#selectionsreadonly EditorSelection[]YesThe #selections member uses the readonly EditorSelection[] contract.EditorSelection
#decorationsreadonly EditorDecoration[]YesThe #decorations member uses the readonly EditorDecoration[] contract.EditorDecoration
#diagnosticsreadonly EditorDiagnostic[]YesThe #diagnostics member uses the readonly EditorDiagnostic[] contract.EditorDiagnostic
#modestringYesThe #mode member uses the string contract.
#viewportAnchorEditorPosition | undefinedNoThe #viewportAnchor member uses the EditorPosition | undefined contract.EditorPosition
#disposedbooleanYesThe #disposed member uses the boolean contract.
__constructoranyYesThe __constructor member uses the any contract.
snapshot() => EditorSnapshotYesThe snapshot member uses the () => EditorSnapshot contract.EditorSnapshot
dispatch(transaction: EditorTransaction) => EditorSnapshotYesThe dispatch member uses the (transaction: EditorTransaction) => EditorSnapshot contract.EditorSnapshot, EditorTransaction
#recordTransactionHistory(changes: readonly EditorChange[], addToHistory: boolean | undefined) => voidYesThe #recordTransactionHistory member uses the (changes: readonly EditorChange[], addToHistory: boolean | undefined) => void contract.EditorChange
#applyTransactionSelection(edits: readonly PreparedEdit[], selections: readonly EditorSelection[] | undefined) => voidYesThe #applyTransactionSelection member uses the (edits: readonly PreparedEdit[], selections: readonly EditorSelection[] | undefined) => void contract.EditorSelection
execute(command: string | EditorCommand, argument?: unknown) => boolean | Promise<boolean>YesThe execute member uses the (command: string | EditorCommand, argument?: unknown) => boolean | Promise<boolean> contract.EditorCommand
#executeSelectionCommand(command: string) => booleanYesThe #executeSelectionCommand member uses the (command: string) => boolean contract.
copy() => string | Promise<string>YesThe copy member uses the () => string | Promise<string> contract.
cut() => string | Promise<string>YesThe cut member uses the () => string | Promise<string> contract.
paste(value?: string) => boolean | Promise<boolean>YesThe paste member uses the (value?: string) => boolean | Promise<boolean> contract.
undo() => booleanYesThe undo member uses the () => boolean contract.
redo() => booleanYesThe redo member uses the () => boolean contract.
#restoreHistory(source: HistoryEntry[], destination: HistoryEntry[]) => booleanYesThe #restoreHistory member uses the (source: HistoryEntry[], destination: HistoryEntry[]) => boolean contract.
search(query: string | RegExp) => readonly EditorRange[]YesThe search member uses the (query: string | RegExp) => readonly EditorRange[] contract.EditorRange
replace(query: string | RegExp, replacement: string, all?: boolean) => numberYesThe replace member uses the (query: string | RegExp, replacement: string, all?: boolean) => number contract.
serialize(format?: "text" | "json" | "markdown") => stringYesThe serialize member uses the (format?: "text" | "json" | "markdown") => string contract.
#exportText() => stringYesThe #exportText member uses the () => string contract.
subscribe(observer: (snapshot: EditorSnapshot) => void) => () => voidYesThe subscribe member uses the (observer: (snapshot: EditorSnapshot) => void) => () => void contract.EditorSnapshot
setDecorations(decorations: readonly EditorDecoration[]) => voidYesThe setDecorations member uses the (decorations: readonly EditorDecoration[]) => void contract.EditorDecoration
setDiagnostics(diagnostics: readonly EditorDiagnostic[]) => voidYesThe setDiagnostics member uses the (diagnostics: readonly EditorDiagnostic[]) => void contract.EditorDiagnostic
setMode(mode: string) => voidYesThe setMode member uses the (mode: string) => void contract.
setViewportAnchor(anchor: EditorPosition) => voidYesThe setViewportAnchor member uses the (anchor: EditorPosition) => void contract.EditorPosition
dispose() => voidYesThe dispose member uses the () => void contract.
#notify() => voidYesThe #notify member uses the () => void contract.
#assertActive() => voidYesThe #assertActive member uses the () => void contract.

Parameters

This declaration has no public members.

Returns

This declaration does not return a value.

Throws

No thrown errors are documented for this declaration.

Source

View the secondary source reference

Package

@mwillbanks/tuil-editor

On this page