diff --git a/apps/playground/src/pages/index.astro b/apps/playground/src/pages/index.astro index 6f3eaa7..b9f680c 100644 --- a/apps/playground/src/pages/index.astro +++ b/apps/playground/src/pages/index.astro @@ -12,6 +12,8 @@ import "../assets/global.css"; Inkwell Playground + +
@@ -53,5 +55,18 @@ import "../assets/global.css"; extensions: [TestExtension], }); + const runCommandBtn = document.getElementById("runCommand"); + const runChainBtn = document.getElementById("runChain"); + + runCommandBtn?.addEventListener("click", () => { + editor.commands.insertContent({ content: "Inserted with command!", from: 0 }) + }); + + runChainBtn?.addEventListener("click", () => { + editor.chain().insertContent({ content: "Inserted with chain!", from: 0 }).run(); + }); + console.log(editor); + // @ts-expect-error we set the editor on window + window.editor = editor diff --git a/packages/core/src/commands/CommandChain.ts b/packages/core/src/commands/CommandChain.ts index cd342ef..63208ce 100644 --- a/packages/core/src/commands/CommandChain.ts +++ b/packages/core/src/commands/CommandChain.ts @@ -4,12 +4,12 @@ import type { ChainedCommands, Command, CommandContext } from "./types.ts"; /** One collected chain step: a command impl and the args to call it with. */ type CommandStep = { - command: Command; - args: unknown; + command: Command; + args: unknown[]; }; /** Flat map of command name to curried impl, populated by ExtensionManager. */ -export type CommandRegistry = Record>; +export type CommandRegistry = Record>; /** * Collects commands + args until `run()` executes them as one dispatch. @@ -35,7 +35,7 @@ export class CommandChain { if (prop in target || typeof prop !== "string") { return Reflect.get(target, prop); } - return (args: unknown) => { + return (...args: unknown[]) => { target.add(prop, args); return proxy; }; @@ -45,7 +45,7 @@ export class CommandChain { } /** Pushes a step by command name. Called by the proxy on property access. */ - private add(name: string, args: unknown): this { + private add(name: string, args: unknown[]): this { const command = this.registry[name]; if (!command) throw new Error(`Command "${name}" is not registered.`); this.steps.push({ command, args }); @@ -88,7 +88,7 @@ export class CommandChain { projected = projected.update(spec).state; }, }; - const succeeded = step.command(ctx)(step.args); + const succeeded = step.command(ctx)(...step.args); if (!succeeded) allSucceeded = false; } diff --git a/packages/core/src/commands/coreExtensions.ts b/packages/core/src/commands/coreExtensions.ts new file mode 100644 index 0000000..8de5985 --- /dev/null +++ b/packages/core/src/commands/coreExtensions.ts @@ -0,0 +1,14 @@ +import type { InkwellExtension } from "../extensions/types.ts"; +import { cutContent } from "./cutContent.ts"; +import { insertContent } from "./insertContent.ts"; +import { setContent } from "./setContent.ts"; + +/** Built-in commands shipped with core. Registered by default. */ +export const coreExtensions: InkwellExtension = { + name: "__tiptap_coreCommands", + addCommands: () => ({ + insertContent, + setContent, + cutContent, + }), +}; diff --git a/packages/core/src/commands/cutContent.ts b/packages/core/src/commands/cutContent.ts new file mode 100644 index 0000000..26bcff9 --- /dev/null +++ b/packages/core/src/commands/cutContent.ts @@ -0,0 +1,37 @@ +import type { NamedCommand } from "./types.ts"; + +declare module "./types.ts" { + interface InkwellCommands { + cutContent: { + /** + * Cuts content from one range and inserts it at another range + * @param fromRange The range to cut from + * @param toRange The range to insert to + * @returns true if the operation was successful + */ + cutContent: ( + fromRange: { from: number; to: number }, + toRange: { from: number; to: number }, + ) => boolean; + }; + } +} + +export const cutContent: NamedCommand<"cutContent"> = + ({ state, dispatch }) => + (fromRange, toRange) => { + const slicedContent = state.doc.sliceString(fromRange.from, fromRange.to); + + dispatch({ + changes: [ + { from: fromRange.from, to: fromRange.to, insert: "" }, + { + from: toRange.from + slicedContent.length, + to: toRange.to + slicedContent.length, + insert: slicedContent, + }, + ], + }); + + return true; + }; diff --git a/packages/core/src/commands/insertContent.ts b/packages/core/src/commands/insertContent.ts new file mode 100644 index 0000000..b6b8d23 --- /dev/null +++ b/packages/core/src/commands/insertContent.ts @@ -0,0 +1,32 @@ +import type { NamedCommand } from "./types.ts"; + +declare module "./types.ts" { + interface InkwellCommands { + /** + * Inserts content into the document at the specified range. + * If to is not provided, it will insert at the from position. + * + * @param args.content The content to insert. + * @param args.from The start position to insert the content. + * @param args.to The end position to insert the content. If not provided, it will insert at the from position. + * @returns A boolean indicating whether the command was executed successfully. + */ + insertContent: { + insertContent: (args: { content: string; from: number; to?: number }) => ReturnType; + }; + } +} + +/** + * Inserts content into the document at the specified range. + * @param ctx The command context. + * @returns A boolean indicating whether the command was executed successfully. + */ +export const insertContent: NamedCommand<"insertContent"> = + ({ dispatch }) => + ({ content, from, to }) => { + dispatch({ + changes: { from, to, insert: content }, + }); + return true; + }; diff --git a/packages/core/src/commands/setContent.ts b/packages/core/src/commands/setContent.ts new file mode 100644 index 0000000..2790630 --- /dev/null +++ b/packages/core/src/commands/setContent.ts @@ -0,0 +1,14 @@ +import { insertContent } from "./insertContent.ts"; +import type { NamedCommand } from "./types.ts"; + +declare module "./types.ts" { + interface InkwellCommands { + setContent: { + setContent: (content: string) => ReturnType; + }; + } +} + +export const setContent: NamedCommand<"setContent"> = (ctx) => (content) => { + return insertContent(ctx)({ content, from: 0, to: ctx.state.doc.length }); +}; diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts index ccbf10e..3d0a192 100644 --- a/packages/core/src/commands/types.ts +++ b/packages/core/src/commands/types.ts @@ -16,7 +16,16 @@ import type { EditorView } from "@codemirror/view"; * } * } */ -export interface InkwellCommands {} +export interface InkwellCommands { + core: { + /** + * Sets the content of the document. + * @param content The content to set. + * @returns A boolean indicating whether the command was executed successfully. + */ + setContent: (content: string) => ReturnType; + }; +} /** Runtime context handed to a command. State is projected (post prior steps). */ export interface CommandContext { @@ -27,7 +36,9 @@ export interface CommandContext { } /** Curried impl: ctx applied at construction, args at call time. Returns false to skip. */ -export type Command = (ctx: CommandContext) => (args: Args) => boolean; +export type Command = ( + ctx: CommandContext, +) => (...args: Args) => boolean; /** Turns a union of object types into an intersection of those types. */ export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( @@ -54,6 +65,8 @@ export type RawCommands = { [K in keyof UnionCommands]: (ctx: CommandContext) => UnionCommands[K]; }; +export type NamedCommand = RawCommands[T]; + /** What `editor.commands` exposes: ctx applied, returns boolean. */ export type SingleCommands = { [K in keyof UnionCommands]: UnionCommands[K]; diff --git a/packages/core/src/editor/Editor.ts b/packages/core/src/editor/Editor.ts index b325efb..e8456d2 100644 --- a/packages/core/src/editor/Editor.ts +++ b/packages/core/src/editor/Editor.ts @@ -3,6 +3,9 @@ import { EditorView } from "@codemirror/view"; import { EventEmitter } from "./EventEmitter.ts"; import type { EditorEvents, EditorOptions } from "./types.ts"; import { ExtensionManager } from "../extensions/index.ts"; +import { CommandChain } from "../commands/CommandChain.ts"; +import { coreExtensions } from "../commands/coreExtensions.ts"; +import type { ChainedCommands, SingleCommands } from "../commands/types.ts"; export class Editor extends EventEmitter { private _te: TextEncoder = new TextEncoder(); @@ -16,6 +19,35 @@ export class Editor extends EventEmitter { return this.view.state; } + /** Starts a command chain. State is snapshotted at run(), not here. */ + chain(): ChainedCommands { + return CommandChain.create(this, this._extensionManager.commands); + } + + /** Bound commands that run immediately as one-step chains. */ + get commands(): SingleCommands { + return this.buildCommands(); + } + + /** Builds the bound commands object from the registry. */ + private buildCommands(): SingleCommands { + const registry = this._extensionManager.commands; + const bound = {} as Record boolean>; + + for (const name of Object.keys(registry)) { + bound[name] = (...args: unknown[]) => { + const chain = CommandChain.create(this, registry); + // Proxy forwards unknown keys to add(name, args), returns the chain. + (chain as unknown as Record ChainedCommands>)[name]( + ...args, + ); + return chain.run(); + }; + } + + return bound as unknown as SingleCommands; + } + public get content(): string { return this.state.doc.toString(); } @@ -56,7 +88,10 @@ export class Editor extends EventEmitter { super(); this.options = options; const { element, content } = this.options; - this._extensionManager = new ExtensionManager(this, this.options?.extensions ?? []); + this._extensionManager = new ExtensionManager(this, [ + coreExtensions, + ...(this.options?.extensions ?? []), + ]); this.emit("beforeCreate", { editor: this }); diff --git a/packages/core/src/extensions/ExtensionManager.ts b/packages/core/src/extensions/ExtensionManager.ts index 6632a89..715dbfe 100644 --- a/packages/core/src/extensions/ExtensionManager.ts +++ b/packages/core/src/extensions/ExtensionManager.ts @@ -2,6 +2,7 @@ import type { Extension as CMExtension } from "@codemirror/state"; import { type KeyBinding, keymap } from "@codemirror/view"; import type { Editor } from "../editor/Editor.ts"; import type { InkwellExtension } from "./types.ts"; +import type { CommandRegistry } from "../commands/CommandChain.ts"; import { resolveExtensionsOnEditor } from "./helpers/resolveExtensionsOnEditor.ts"; /** @@ -23,6 +24,9 @@ export class ExtensionManager { /** Stores keybindings before they become one CodeMirror keymap. */ private _keybindings: CMExtension[] = []; + /** Flat map of command name to curried impl, collected from extensions. */ + private _commands: CommandRegistry = {}; + /** * Creates one extension setup lifecycle for the editor and its configured extensions. * @@ -59,6 +63,11 @@ export class ExtensionManager { return [...this._resolvedExtensions]; } + /** Provides the collected command registry for editor.commands and chain(). */ + get commands(): CommandRegistry { + return { ...this._commands }; + } + /** * Expands child extensions so their contributions are included in editor setup. * @@ -96,13 +105,10 @@ export class ExtensionManager { // TODO: implement mark binding logic, noop for now } - /** - * Reserves command registration until Inkwell exposes a command registry. - * - * @param addCommands - Provides commands from one extension. - */ + /** Collects commands from one extension into the registry. */ private bindCommands(addCommands: NonNullable) { - // TODO: implement command binding logic, noop for now + const commands = addCommands({ editor: this.editor }) ?? {}; + Object.assign(this._commands, commands); } /** diff --git a/packages/core/src/extensions/types.ts b/packages/core/src/extensions/types.ts index b80d647..e6225d4 100644 --- a/packages/core/src/extensions/types.ts +++ b/packages/core/src/extensions/types.ts @@ -35,7 +35,9 @@ export type InkwellExtension = ExtensionEventHandlers & { addMarks?: (ctx: EditorContext) => MarkConfig[]; // Loose: core can't see InkwellCommands augmentations. Authors can annotate // returns as Partial at the call site for per-command checking. - addCommands?: (ctx: EditorContext) => Record>; + // Command (not unknown) because args is contravariant — specific command + // types must be assignable to the registry type. + addCommands?: (ctx: EditorContext) => Record>; addCodeMirrorExtensions?: (ctx: EditorContext) => CMExtension[]; addExtensions?: (ctx: EditorContext) => InkwellExtension[];