diff --git a/packages/core/src/commands/CommandTransaction.ts b/packages/core/src/commands/CommandTransaction.ts index 7540e02..b58a4be 100644 --- a/packages/core/src/commands/CommandTransaction.ts +++ b/packages/core/src/commands/CommandTransaction.ts @@ -2,24 +2,45 @@ import { MapMode, type EditorState, type StateCommand, type Transaction } from " import type { Editor } from "../editor/Editor.ts"; /** - * An immutable sequence of CodeMirror transactions that can be dispatched together. + * An immutable, deferred sequence of CodeMirror transactions. + * + * Every command receives this transaction's projected state, so selections and positions read from + * that state already reflect prior commands. Dispatch the completed transaction once with + * {@link dispatch} or {@link Editor.dispatch}. */ export class CommandTransaction { private constructor( + /** The editor that owns this command transaction. */ readonly editor: Editor, + + /** The editor state from which the first command started. */ readonly startState: EditorState, + + /** Native CodeMirror transactions captured from each command invocation. */ readonly transactions: readonly Transaction[], + + /** The state projected after all captured transactions. */ readonly state: EditorState, + + /** Whether the owning editor should be focused after dispatch. */ readonly focusRequested: boolean, ) {} - /** Starts a deferred command transaction from the editor's current state. */ + /** Starts a deferred command transaction from an editor's current state. */ static fromEditor(editor: Editor): CommandTransaction { return new CommandTransaction(editor, editor.state, [], editor.state, false); } /** - * Maps a position from the state where this transaction started to its current projected state. + * Maps a position from the initial document to the current projected document. + * + * Use this for positions supplied as command arguments. Positions read from {@link state} are + * already in the projected document and must not be mapped again. + * + * @param position - A position in {@link startState}. + * @param assoc - Which side of an insertion at this position to associate with. + * @param mode - Determines how positions inside deleted content are handled. + * @returns The projected position, or `null` when the selected map mode tracks a deletion. */ mapPosition(position: number, assoc?: number, mode: MapMode = MapMode.Simple): number | null { let mapped: number | null = position; @@ -35,7 +56,10 @@ export class CommandTransaction { } /** - * Runs a native CodeMirror state command against the current projected editor state. + * Runs a native CodeMirror state command against the projected state. + * + * This is useful when adapting an existing CodeMirror command directly. A command may dispatch + * at most one transaction and must return `true` when it does. */ run(command: StateCommand): CommandTransaction { let dispatched: Transaction | undefined; @@ -74,7 +98,11 @@ export class CommandTransaction { ); } - /** Defers focus until the command transaction is dispatched. */ + /** + * Defers focus until this transaction is dispatched. + * + * Most custom commands should call {@link CommandContext.requestFocus} instead. + */ requestFocus(): CommandTransaction { if (this.focusRequested) { return this; @@ -89,7 +117,12 @@ export class CommandTransaction { ); } - /** Dispatches all captured transactions as one CodeMirror view update. */ + /** + * Dispatches all captured CodeMirror transactions as one view update. + * + * Throws when the editor changed since this transaction started. Use {@link Editor.dispatch} for + * the equivalent editor-owned call. + */ dispatch(): void { if (this.editor.state !== this.startState) { throw new Error("Cannot dispatch a command transaction after the editor state changed."); diff --git a/packages/core/src/commands/builtin/insertDefaultBlock.ts b/packages/core/src/commands/builtin/insertDefaultBlock.ts index 5076337..081ddb8 100644 --- a/packages/core/src/commands/builtin/insertDefaultBlock.ts +++ b/packages/core/src/commands/builtin/insertDefaultBlock.ts @@ -1,5 +1,15 @@ import { defineCommand } from "../helpers/index.ts"; +/** + * Inserts an empty default block at a position from the command transaction's initial document. + * + * The command maps the position through preceding commands, inserts two line breaks, and moves the + * cursor to the new block. + * + * @param target - An editor to start from, or a prior command transaction to continue. + * @param position - The insertion position in the initial document. + * @returns The updated deferred command transaction. + */ export const insertDefaultBlock = defineCommand( ({ mapPosition, dispatch, state }, position: number) => { const mappedPosition = mapPosition(position); diff --git a/packages/core/src/commands/builtin/setSelection.ts b/packages/core/src/commands/builtin/setSelection.ts index 21c6079..3c2858b 100644 --- a/packages/core/src/commands/builtin/setSelection.ts +++ b/packages/core/src/commands/builtin/setSelection.ts @@ -2,13 +2,40 @@ import { EditorSelection } from "@codemirror/state"; import { defineCommand } from "../helpers/index.ts"; export type SetSelectionOptions = { + /** The selection anchor in the command transaction's initial document. */ from: number; + + /** The selection head in the command transaction's initial document. Defaults to {@link from}. */ to?: number; + + /** Scrolls the resulting selection into view. Defaults to `true`. */ scrollIntoView?: boolean; + + /** Focuses the editor after the completed command transaction is dispatched. */ focus?: boolean; }; -/** Sets the main selection using positions from the command transaction's start state. */ +/** + * Sets the main editor selection using positions from the command transaction's initial document. + * + * Both positions are mapped through preceding commands. Provide only `from` to place a cursor, or + * provide `from` and `to` to create a range selection. + * + * @param target - An editor to start from, or a prior command transaction to continue. + * @param options - The selection coordinates and optional view behavior. + * @returns The updated deferred command transaction. + * + * @example + * ```ts + * const transaction = setSelection(editor, { + * from: 4, + * to: 8, + * focus: true, + * }); + * + * editor.dispatch(transaction); + * ``` + */ export const setSelection = defineCommand( ({ state, dispatch, mapPosition, requestFocus }, options: SetSelectionOptions) => { const from = mapPosition(options.from); diff --git a/packages/core/src/commands/helpers/defineCommand.ts b/packages/core/src/commands/helpers/defineCommand.ts index 21dddf4..09650b2 100644 --- a/packages/core/src/commands/helpers/defineCommand.ts +++ b/packages/core/src/commands/helpers/defineCommand.ts @@ -1,7 +1,37 @@ import { CommandTransaction } from "../CommandTransaction.ts"; import type { Command, CommandImplementation } from "../types.ts"; -/** Defines an Inkwell command while preserving CodeMirror's state-command contract. */ +/** + * Defines a deferred Inkwell command. + * + * The implementation receives the current projected CodeMirror state and a dispatcher that captures + * its transaction. Its public command accepts either an editor or a prior command transaction as + * the first argument, followed by the arguments inferred from the implementation. + * + * Return `false` when the command cannot apply. Otherwise, dispatch exactly one transaction with + * `context.dispatch(state.update(...))` and return `true`. + * + * @typeParam Args - The arguments accepted after the editor or command transaction target. + * @param implementation - Creates one deferred command operation. + * @returns A command that can start or continue a deferred transaction. + * + * @example + * ```ts + * const insertText = defineCommand( + * ({ state, dispatch, mapPosition }, position: number, text: string) => { + * const from = mapPosition(position); + * if (from === null) return false; + * + * dispatch(state.update({ changes: { from, insert: text } })); + * return true; + * }, + * ); + * + * let transaction = insertText(editor, 0, "Hello "); + * transaction = insertText(transaction, 5, "Inkwell"); + * editor.dispatch(transaction); + * ``` + */ export function defineCommand( implementation: CommandImplementation, ): Command { diff --git a/packages/core/src/commands/helpers/fromStateCommand.ts b/packages/core/src/commands/helpers/fromStateCommand.ts index 7464c30..f04f5d7 100644 --- a/packages/core/src/commands/helpers/fromStateCommand.ts +++ b/packages/core/src/commands/helpers/fromStateCommand.ts @@ -1,7 +1,16 @@ import { defineCommand } from "./defineCommand.ts"; import type { Command, StateCommandFactory } from "../types.ts"; -/** Adapts a native CodeMirror state-command factory for deferred Inkwell execution. */ +/** + * Adapts a native CodeMirror state-command factory for deferred Inkwell execution. + * + * Use this for commands from `@codemirror/*` or existing state-command factories that already use + * CodeMirror's `{ state, dispatch }` command target. + * + * @typeParam Args - The arguments accepted by the state-command factory. + * @param factory - Creates a native CodeMirror state command. + * @returns An Inkwell command that participates in deferred dispatch. + */ export function fromStateCommand( factory: StateCommandFactory, ): Command { diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts index ce7fee0..5e4b0ba 100644 --- a/packages/core/src/commands/types.ts +++ b/packages/core/src/commands/types.ts @@ -7,28 +7,49 @@ import { import type { Editor } from "../editor/Editor.ts"; import type { CommandTransaction } from "./CommandTransaction.ts"; -/** The value a command can start from. */ +/** + * The value a command runs against. + * + * Pass an {@link Editor} to start a deferred command transaction, or pass the result of a previous + * command to continue that transaction without dispatching it yet. + */ export type CommandTarget = Editor | CommandTransaction; -/** A deferred editor command with caller-defined arguments. */ +/** + * A deferred editor command with caller-defined arguments. + * + * Commands return a {@link CommandTransaction}. Pass that result to another command, then dispatch + * it through {@link Editor.dispatch} when the complete operation is ready to apply. + */ export type Command = ( target: CommandTarget, ...args: Args ) => CommandTransaction; -/** The CodeMirror state-command target plus Inkwell's position mapper. */ +/** Context provided to a {@link CommandImplementation}. */ export type CommandContext = { + /** The state after every command that precedes the current command. */ state: EditorState; + + /** Captures one CodeMirror transaction for deferred dispatch. */ dispatch: (transaction: Transaction) => void; + + /** Maps a position from the transaction's initial document to the current projected document. */ mapPosition: (position: number, assoc?: number, mode?: MapMode) => number | null; + + /** Requests focus after the final command transaction is dispatched. */ requestFocus: () => void; }; -/** Implements one command invocation. Returning false leaves the transaction unchanged. */ +/** + * Implements one command invocation. + * + * Return `false` when the command does not apply. The command transaction remains unchanged. + */ export type CommandImplementation = ( context: CommandContext, ...args: Args ) => boolean; -/** Creates a CodeMirror state command after receiving command-specific arguments. */ +/** Creates a native CodeMirror state command after receiving command-specific arguments. */ export type StateCommandFactory = (...args: Args) => StateCommand; diff --git a/packages/core/src/editor/Editor.ts b/packages/core/src/editor/Editor.ts index 28e9ee1..bcd175e 100644 --- a/packages/core/src/editor/Editor.ts +++ b/packages/core/src/editor/Editor.ts @@ -89,7 +89,18 @@ export class Editor extends EventEmitter { this.view.destroy(); } - /** Dispatches a CodeMirror transaction or transaction specification. */ + /** + * Dispatches a CodeMirror transaction, transaction specification, or deferred command transaction. + * + * Passing a {@link CommandTransaction} applies all commands in its sequence as one view update and + * then runs deferred view effects such as focus. + * + * @example + * ```ts + * const transaction = setSelection(editor, { from: 0, focus: true }); + * editor.dispatch(transaction); + * ``` + */ public dispatch(commandTransaction: CommandTransaction): void; public dispatch(transaction: Transaction): void; public dispatch(transactions: readonly Transaction[]): void;