diff --git a/docs/diagrams/plugin-lifecycle-flow.mmd b/docs/diagrams/plugin-lifecycle-flow.mmd index 4d9cb628..7c9d9250 100644 --- a/docs/diagrams/plugin-lifecycle-flow.mmd +++ b/docs/diagrams/plugin-lifecycle-flow.mmd @@ -12,6 +12,7 @@ sequenceDiagram participant EditorJSModel participant BlockRenderer participant Plugin as EditorjsPlugin + participant Registry as PluginRegistry participant CollabMgr as CollaborationManager participant Adapter as EditorJSAdapterPlugin participant Tool as BlockTool / InlineTool @@ -41,6 +42,8 @@ sequenceDiagram Plugins-->>Core: [SomePlugin, CollaborationManager, ...] Core->>Plugin: new SomePlugin(config, api, eventBus) Plugin->>EventBus: wire event listeners + Core->>Registry: register(SomePlugin.name, plugin.publicApi) (if exposed) + Note over Registry: shared record behind api.plugins;
read at access time, so plugins constructed
earlier still see later registrations Core->>CollabMgr: new CollaborationManager(config, api, eventBus) CollabMgr->>EventBus: wire event listeners @@ -67,3 +70,4 @@ sequenceDiagram %% ── Destroy ────────────────────────────────────── Dev->>Plugin: plugin.destroy() Plugin->>EventBus: removeEventListener (cleanup) + Note over Registry: the registry lives for the lifetime of the Core
instance — Core does not retain plugin instances,
so nothing calls unregister() on teardown today diff --git a/docs/plugins.md b/docs/plugins.md index 6447b7b5..1780e203 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -8,6 +8,17 @@ Tools and plugins should only depend on `@editorjs/sdk` — never on `@editorjs/ `core.use(...)` registers UI components/plugins by static `type` (values from `ToolType` for tools, `PluginType.Adapter` for adapters, and `PluginType.Plugin` for general plugins). +Every plugin must also declare a static `name` — its id across the editor. It keys the runtime registry behind `api.plugins` **and** the compile-time type maps: + +```ts +export class ShortcutsPlugin implements EditorjsPlugin<'shortcuts'> { + public static readonly type = PluginType.Plugin; + public static readonly name = 'shortcuts'; +} +``` + +Declare it as `public static readonly name = '…'` with no type annotation, so TypeScript infers the literal. Note that every class already inherits `name: string` from `Function`, so omitting the declaration does **not** fail on its own — the plugin silently registers under its class name, which a production build minifies. It does fail as soon as the plugin exposes a `publicApi`: the id widens to `string`, `publicApi` resolves to `never`, and `core.use()` rejects it. + Tools are registered via `core.use(ToolConstructor, options)` during setup. The `tools` config field provides tool settings/options that `ToolsManager` applies during `initialize()`. | Type | Interface / Source | Purpose | @@ -32,10 +43,216 @@ Canonical startup order: - Plugins receive dependencies via constructor params (`config`, `api`, `eventBus`). - Plugin instances may implement `destroy()`, but `Core` currently does not expose a global `destroy()` lifecycle hook. +- Plugins are constructed in registration order, and `api.plugins` is populated as each one is constructed. **Reading another plugin's API inside your constructor returns `undefined`** — do it after `core:ready`, or lazily inside an event handler. `api.plugins` resolves entries at access time, so a plugin constructed first still sees one registered later. + +## Keyboard input + +`BlocksUI` delegates every native `keydown` on the blocks holder as a `KeydownUIEvent` before doing anything with the key itself. A plugin that handles a key calls `preventDefault()` on the native event; `BlocksUI` sees that and skips its own handling, so plugin shortcuts take precedence over the built-in undo/redo bindings. + +## Plugin public APIs + +A plugin exposes callable surface by declaring `publicApi`; `Core` registers it under the plugin's `name` and serves it from `api.plugins`: + +```ts +export interface ShortcutsPluginApi { + register(shortcut: string, handler: (event: KeyboardEvent) => void): void; + unregister(shortcut: string): void; +} + +declare module '@editorjs/sdk' { + interface EditorjsPluginApiMap { + shortcuts: ShortcutsPluginApi; + } +} + +export class ShortcutsPlugin implements EditorjsPlugin<'shortcuts'> { + public static readonly name = 'shortcuts'; + public readonly publicApi: ShortcutsPluginApi = { /* … */ }; +} +``` + +Consumers — the integrator or another plugin — then call it with full inference and no imports at the call site: + +```ts +api.plugins.shortcuts?.register('CMD+K', openSearch); +``` + +Plugin names share one flat namespace: registering two plugins under the same name throws at +initialization rather than silently overwriting. See [TypeScript caveats](#typescript-caveats) +for what the compiler does and does not check. + +## Tool → plugin configuration + +Tools address configuration to a plugin under `options.plugins.`, typed by the `ToolPluginOptionsMap` augmentation: + +```ts +declare module '@editorjs/sdk' { + interface ToolPluginOptionsMap { + shortcuts: { shortcut?: string }; + } +} + +export class BoldInlineTool implements InlineTool { + public static readonly options = { + title: 'Bold', + plugins: { + shortcuts: { shortcut: 'CMD+B' }, + }, + }; +} +``` + +The same key works in the second argument of `use()`, and the integrator's value wins: + +```ts +core.use(BoldInlineTool, { plugins: { shortcuts: { shortcut: 'CMD+SHIFT+B' } } }); +``` + +Merging is shallow **at the plugin-id level**: a slice supplied through `use()` replaces the tool's static slice for that id wholesale (so an integrator can drop a key the tool declared), while ids present in only one source are preserved. A plugin reads only its own slice: + +```ts +const { shortcut } = toolFacade.pluginOptions('shortcuts') ?? {}; +``` + +## TypeScript caveats + +Both features — calling a plugin API and configuring a plugin from a tool — are typed the same +way: `@editorjs/sdk` declares two empty interfaces (`EditorjsPluginApiMap`, `ToolPluginOptionsMap`) +and each plugin package fills in its own row via module augmentation. That gives inference with no +casts, but it comes with rules worth knowing before you hit them. + +### The augmentation must be in *your* compilation + +A row exists only for programs that include the declaring file. If your program does not, the map +is empty there and `api.plugins.shortcuts` simply does not compile. + +Three import forms work. None produces a runtime import, so a `devDependency` is enough — the +two `import type` forms vanish from the emitted JS entirely, and the directive survives only as a +comment: + +```ts +import type {} from '@editorjs/shortcuts'; // augmentation only +import type { ShortcutsPluginApi } from '@editorjs/shortcuts'; // when you name the type +/// // no import statement +``` + +Prefer the empty-import or `reference` form when you only need `api.plugins.x` to typecheck — an +unused named import invites someone to "clean it up" and silently break the typing. + +If the type appears in your **own** public API (a return type, a public field), it leaks into your +`.d.ts` and downstream consumers need the package too — then it must be a real `dependency`. + +### The plugin package must expose its augmentation from its entry point + +`import type {} from '@editorjs/some-plugin'` only pulls in what the package's `types` entry +reaches. A plugin whose augmentation lives in a module the entry never re-exports is invisible, +and a deep import (`@editorjs/core/dist/plugins/ShortcutsPlugin.js`) is the only way in — not +something to ship. Plugin packages should `export` the plugin class and its API/options types +from `src/index.ts`. + +> `ShortcutsPlugin` currently lives inside `@editorjs/core` and is **not** re-exported, so its +> types are not reachable by any consumer. It also cannot be reached from packages `core` itself +> depends on (`dom-adapters`, `ui`) — that would be a dependency cycle. Extracting it to +> `packages/plugins/shortcuts` (deps: `sdk` only), the way `clipboard-plugin` and `inline-link` +> were extracted, is the fix. + +### The type map is global; the registry is per editor instance + +`EditorjsPluginApiMap` is global to a compilation, but `PluginRegistry` is per `Core`. Types will +claim `api.plugins.shortcuts` exists on an editor that never registered the plugin. That is why +every entry is optional — **always use `?.`**, and treat `undefined` as "not installed" rather +than an error: + +```ts +api.plugins.shortcuts?.register('CMD+K', openSearch); +``` + +### A tool's `static options` is not checked where it is written + +This one surprises people. Adding a nonsense plugin id to a tool produces **no error**: + +```ts +public static readonly options = { + plugins: { + totallyMadeUpPlugin: { whatever: 123 }, // compiles fine 🙁 + }, +}; +``` + +Two reasons, and both must be fixed for the check to fire: + +1. **No contextual type.** `static readonly options = {…}` is an unannotated object literal. + TypeScript excess-property-checks a *fresh literal against a target type*; here there is no + target, so nothing is compared. +2. **An empty map accepts everything.** A tool package that depends only on `@editorjs/sdk` sees + no augmentations, so `ToolPluginOptionsMap` is `{}` and `Partial<{}>` permits any key. + +Passing the class to `core.use(Tool)` does not catch it either: that is a type-to-type +assignability check, and extra properties are legal in structural assignability. What **is** +checked is the second argument, because that one is a fresh literal: + +```ts +core.use(BoldInlineTool, { plugins: { nope: {} } }); +// ✅ Object literal may only specify known properties, and 'nope' does not exist +// in type 'Partial' +``` + +### Opting in to full checking on a tool + +To get keys *and* value types checked where you write them, add `satisfies` **and** depend on the +types of the plugin you are configuring: + +```ts +import type {} from '@editorjs/shortcuts'; +import type { InlineToolOptions } from '@editorjs/sdk'; + +export class BoldInlineTool implements InlineTool { + public static readonly options = { + title: 'Bold', + plugins: { + shortcuts: { shortcut: 'CMD+B' }, + }, + } satisfies InlineToolOptions; +} +``` + +Now a typo or a wrong value type fails at the declaration: + +``` +error TS2353: Object literal may only specify known properties, +and 'totallyMadeUpPlugin' does not exist in type 'Partial'. +``` + +`satisfies` keeps the literal type (unlike a type annotation), so nothing is widened. The cost is +that the tool package now depends on the plugin package for types. That is a real coupling +decision: it is reasonable for a tool that ships opinionated defaults for a plugin, and +unreasonable for a tool that just happens to mention one. + +### Why the framework does not enforce this for you + +An automatic check at `core.use()` was prototyped and rejected. A conditional type can reject +plugin ids absent from `ToolPluginOptionsMap`, and it does catch real typos — but TypeScript +cannot distinguish *"this id is a typo"* from *"this id's package is not imported here"*. Both +look like a missing key. The result was a false positive on valid code: registering +`BoldInlineTool` from a package without the `shortcuts` augmentation failed with +`'options.plugins addresses an unknown plugin': "shortcuts"`, even though bold's configuration is +correct and harmless at runtime. + +That failure mode gets worse, not better, as tool and plugin registration moves out of `core` into +separate packages: the check resolves against whatever the *calling* package imports, so an +assembling package would be forced to import the types of every plugin any of its tools mentions. +Since an unknown slice is simply ignored at runtime, opt-in `satisfies` is the sound trade. + +## Migration + +Two breaking changes came with the plugin public API work: + +- **`options.shortcut` is no longer read.** `ShortcutsPlugin` now sources shortcuts only from `options.plugins.shortcuts`. Move `shortcut: 'CMD+B'` under `plugins: { shortcuts: { shortcut: 'CMD+B' } }`, in the tool's `static options` or in the `use()` argument. The old flat key still type-checks (tool options carry an index signature), so it fails silently — grep for it. +- **Plugin constructors need a static `name`.** Add `public static readonly name = 'my-plugin'`. Omitting it is a compile error only for plugins that expose a `publicApi` or accept tool options; other plugins fall back to the (minifiable) class name, so declare it regardless. ## EditorAPI -Every plugin and tool receives an `api` object of type `EditorAPI` in its constructor. It is composed of three namespaces: +Every plugin and tool receives an `api` object of type `EditorAPI` in its constructor. It is composed of these namespaces: ### `api.blocks` @@ -72,6 +289,9 @@ Document access and mutations — delegates to `DocumentAPI`. | `undo()` | Undo the last change in the document (dispatches `UndoCoreEvent`) | | `redo()` | Redo the last undone change (dispatches `RedoCoreEvent`) | +### `api.plugins` + +Public APIs exposed by the registered plugins, keyed by plugin `name` — see [Plugin public APIs](#plugin-public-apis). Backed by `PluginRegistry`; every entry is optional. → [`diagrams/plugin-lifecycle-flow.mmd`](diagrams/plugin-lifecycle-flow.mmd) diff --git a/openspec/changes/add-plugin-public-api/.openspec.yaml b/openspec/changes/add-plugin-public-api/.openspec.yaml new file mode 100644 index 00000000..9e5b8a19 --- /dev/null +++ b/openspec/changes/add-plugin-public-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-23 diff --git a/openspec/changes/add-plugin-public-api/design.md b/openspec/changes/add-plugin-public-api/design.md new file mode 100644 index 00000000..b00d97af --- /dev/null +++ b/openspec/changes/add-plugin-public-api/design.md @@ -0,0 +1,180 @@ +## Context + +Plugins are registered with `core.use(PluginCtor)`, instantiated in `Core.#initializePlugin()` with `{ config, api, eventBus }`, and then dropped — `Core` keeps no reference to the instance. Communication is one-way: plugins listen on the `EventBus` and push through `EditorAPI`. Nothing can call *into* a plugin. + +The one existing case of a tool configuring a plugin is `ShortcutsPlugin`, which reads `tool.options['shortcut']` off a `BlockToolOptions` whose `[key: string]: unknown` index signature makes any key legal and none checked. That escape hatch is the de-facto extension point today, and it does not scale past one plugin: two plugins wanting a `shortcut` key would silently fight, and neither can express what shape it expects. + +This change adds the two missing directions — plugin → outside world (public API) and tool → plugin (namespaced options) — with one shared identity (`name`) and one shared typing technique (declaration merging), so the two features stay symmetric rather than becoming two unrelated mechanisms. + +Supersedes parts of `docs/plugins.md` (Registration, EditorAPI, Lifecycle boundary) and extends `docs/diagrams/plugin-lifecycle-flow.mmd`. + +## Goals / Non-Goals + +**Goals:** +- A plugin can expose a callable public API reachable by the integrator and by other plugins. +- A tool can declare configuration addressed to a specific plugin, and the plugin can read only its own slice. +- Both are type-safe end to end with no casts at any call site, and generic — nothing in `sdk` or `core` knows the name `shortcuts`. +- `ShortcutsPlugin` and the built-in inline tools migrate onto the new mechanism, proving it on a real case. + +**Non-Goals:** +- A plugin lifecycle beyond construction (no `onReady`/`onDestroy` hooks, no `Core.destroy()`). Registry teardown hangs off the existing `destroy()` contract only. +- Cross-plugin API access *during* plugin construction. The contract is "after `core:ready`". +- Runtime validation of plugin option slices against a schema — typing is compile-time; a plugin validates its own slice if it wants to. +- Versioning or capability negotiation between plugin APIs. +- Deep-merging tool option slices. + +## Decisions + +### 1. `name` as the single key for both features + +Every plugin constructor declares `static readonly name = 'shortcuts'` (no type annotation, so TS infers the literal type). That literal keys the runtime registry, `api.plugins`, and `options.plugins`. `name` is chosen over a dedicated `pluginId` so plugins and tools share one convention — `BaseToolConstructor` already identifies tools by a static `name`. + +**Interaction with `Function.name`.** Every class already has a static `name` typed `string`, so `name` is the one identifier that cannot be *required* by the type system: a plugin that forgets to declare it still satisfies `{ name: string }` and silently registers under its class name — which a production build will have minified to `t`. This mirrors the documented tool behavior ("falls back to the JavaScript class name if not explicitly set"), so the fallback is consistent, but it is a real hole where a `pluginId` would have produced a compile error. Two mitigations, both cheap, both applied: + +- The `use()` overload constrains `Id extends PluginId` (decision 3). A plugin exposing a `publicApi` or accepting tool options must have a `name` that is a *key of one of the maps*; a bare `string` from `Function.name` does not narrow to a key, so those plugins fail to compile without an explicit declaration. Only plugins that augment neither map — which have nothing to key — can fall through to the class name. +- `PluginRegistry.register()` rejects a name that is absent from a small runtime allowlist check: it throws when the name is empty or when it duplicates an existing entry, which is the observable symptom of two minified classes colliding. + +*Alternative rejected:* a dedicated `pluginId` property. It is strictly safer — no `Function.name` shadow, so a missing declaration is always a compile error — but it introduces a second identity convention alongside tools' `name` for no gain the mitigations above do not already cover. + +*Alternative rejected:* using the constructor itself as the key (`api.plugins.get(ShortcutsPlugin)`). It avoids a global name space and collisions, but forces every call site — including tool option declarations, which are static object literals — to import the plugin class. That is the wrong trade for tool config, and having two different keying schemes for the two features defeats the symmetry. + +*Alternative rejected:* `unique symbol` ids. They give collision-proof keys usable as computed properties, but symbols cannot be serialized into a plain-JSON-ish `options` object and are awkward to inspect while debugging. + +### 2. Two augmentable interfaces in `@editorjs/sdk` + +```ts +// sdk +export interface EditorjsPluginApiMap {} // name -> public API type +export interface ToolPluginOptionsMap {} // name -> tool-directed options type + +export type PluginsAPI = Partial; +export type ToolPluginOptions = Partial; +``` + +A plugin package augments both from its own entry point: + +```ts +// @editorjs/shortcuts (or core/src/plugins/ShortcutsPlugin.ts today) +declare module '@editorjs/sdk' { + interface EditorjsPluginApiMap { shortcuts: ShortcutsPluginApi; } + interface ToolPluginOptionsMap { shortcuts: ShortcutsToolOptions; } +} +``` + +`Partial<>` is what makes "known key → typed, unknown key → compile error, present key → possibly `undefined`" all fall out for free. Empty base interfaces mean `sdk` compiles standalone with zero permitted keys, which is the correct default. + +*Alternative rejected:* keeping `[key: string]: unknown` and layering runtime schema validation. It never produces call-site inference, which is the whole ask. + +### 3. Binding `name` to the augmented key at registration + +TypeScript does not check statics through `implements`, so a plugin could declare `name = 'shortcut'` while augmenting the map under `shortcuts`. The check is placed on `core.use()` instead of requiring boilerplate in every plugin package: + +```ts +export interface EditorjsPlugin { + destroy?(): void; + publicApi?: Id extends keyof EditorjsPluginApiMap ? EditorjsPluginApiMap[Id] : never; +} + +export interface EditorjsPluginConstructor< + Id extends PluginId = PluginId, + Instance extends EditorjsPlugin = EditorjsPlugin +> { + new (params: EditorjsPluginParams): Instance; + type: EntityType; + name: Id; +} + +// Core +public use(plugin: EditorjsPluginConstructor): Core; +``` + +`Id` is inferred from the `name` literal; the instance's `publicApi` is then checked against `EditorjsPluginApiMap[Id]`. A drifted id fails at the `use()` call — the one place every plugin passes through. + +`PluginId = keyof EditorjsPluginApiMap | keyof ToolPluginOptionsMap | (string & {})`: the `string & {}` arm keeps ids legal for plugins that augment neither map (they have no public API and take no tool config) while preserving literal-type inference and autocomplete for the known ids. + +Because `name` is shadowed by `Function.name` (decision 1), that `string & {}` arm is also what a forgotten declaration falls into. The conditional in `EditorjsPlugin` is what closes the gap: when `Id` widens to `string`, `Id extends keyof EditorjsPluginApiMap` is false, so `publicApi` resolves to `never` — a plugin that actually declares one gets "Type `ShortcutsPluginApi` is not assignable to type `never`" at the `use()` site, which is the diagnostic pointing at the missing `static readonly name`. The same holds for the options side via `pluginOptions()`, whose `Id` parameter is constrained to `keyof ToolPluginOptionsMap` with no `string` arm at all. + +### 4. `api.plugins` is a live record, not a snapshot + +Plugins are constructed sequentially and each receives `EditorAPI` in its constructor, so a plugin constructed first would capture an incomplete registry if `plugins` were copied. Instead one mutable record object is created before any plugin is instantiated and shared by reference; `PluginRegistry` (core-internal) writes into it as each plugin is constructed. + +```ts +class PluginRegistry { + readonly #record: Record = {}; + public get api(): PluginsAPI; // the shared record + public register(id: string, publicApi: unknown): void; // throws on duplicate id + public unregister(id: string): void; +} +``` + +`EditorAPI.plugins` is a getter delegating to `registry.api`, so no IoC binding-order constraint appears. Because consumers read `api.plugins.x` at call time (inside an event handler, after `ready`), late writes are visible. + +*Alternative rejected:* a `Proxy` over a `Map`. Equivalent behavior, but it breaks `in`/spread/devtools inspection and needs a cast to the mapped type anyway. + +Duplicate-id detection lives in `register()` and throws — currently `Core.initialize()` wraps everything in a try/catch that only `console.error`s, so the failure surfaces there; tightening that is out of scope. + +### 5. Public API is a declared member, read once after construction + +The plugin exposes `public readonly publicApi: ShortcutsPluginApi` (or a getter). `Core.#initializePlugin()` reads it right after `new plugin(...)` and calls `registry.register(Ctor.name, instance.publicApi)` when it is not `undefined`. Reading once keeps the "same object for every consumer" guarantee from the spec, so a plugin API may hold state. + +Named `publicApi`, not `api` — plugins already hold `#api: EditorAPI`, and reusing the name would read as the editor API on both sides of the boundary. + +### 6. Tool options: `plugins` key merged per id + +```ts +export interface BaseToolOptions { + config?: Config; + plugins?: ToolPluginOptions; +} +``` + +`BaseToolFacade` gains: + +```ts +public pluginOptions(id: Id): ToolPluginOptionsMap[Id] | undefined; +``` + +Merge rule: **shallow at the id level** — `{ ...staticOptions.plugins, ...useOptions.plugins }`. Disjoint ids from both sources survive; when both supply the same id, the `use()` slice replaces the static one wholesale. No deep merge inside a slice: it matches the precedence users already see for `options`, and a deep merge would make it impossible for an integrator to *remove* a key a tool declared. The existing `options` getter is updated to apply the same per-id merge to `plugins`, so `facade.options.plugins` and `facade.pluginOptions(id)` never disagree. + +### 7. ShortcutsPlugin as the proving case + +```ts +interface ShortcutsToolOptions { shortcut?: string; } + +interface ShortcutsPluginApi { + register(shortcut: string, handler: (event: KeyboardEvent) => void): void; + unregister(shortcut: string): void; +} +``` + +Tool-declared shortcuts are registered through the same internal path as API-registered ones, so there is a single lookup table and one precedence rule. On `ToolLoadedCoreEvent` the plugin calls `facade.pluginOptions('shortcuts')` and, when a `shortcut` is present, registers a handler applying that inline tool. The existing `#processBlockTool`/`#processBlockTune` `@todo` stubs stay as-is; the `shortcuts` map for block tools becomes a natural extension of `ShortcutsToolOptions` later. + +`bold`, `italic`, and `inline-link` move `shortcut: 'CMD+B'` → `plugins: { shortcuts: { shortcut: 'CMD+B' } }`. + +`ShortcutsPlugin` lives in `packages/core/src/plugins/`, so its module augmentation ships from `@editorjs/core`. That is acceptable while it is a built-in; extracting it to `packages/plugins/shortcuts` (as `inline-link` and `clipboard-plugin` were extracted) is a follow-up, and the augmentation moves with it unchanged. + +## Risks / Trade-offs + +- **Global type namespace vs. per-instance runtime registry** → `EditorjsPluginApiMap` is global to a compilation, but the registry is per `Core` instance. Types will claim `api.plugins.shortcuts` exists on an editor that never registered the plugin. Mitigated by `Partial<>` making every entry `| undefined`, forcing `?.` at call sites; documented explicitly in `docs/plugins.md`. +- **Augmentation only applies if the plugin's types are in the compilation** → a plugin loaded purely at runtime gives no keys and the access fails to compile. Mitigated by requiring plugin packages to export their augmentation from the package entry point, so a plain `import '@editorjs/shortcuts'` is enough. +- **Plugin id collisions across the ecosystem** → a single flat namespace. Mitigated by the runtime duplicate check throwing at registration, so collisions are loud rather than silent; naming guidance goes in `docs/plugins.md`. +- **Cross-plugin access during construction returns `undefined`** → a plugin reaching for another's API in its constructor gets `undefined` with no diagnostic. Mitigated by documenting the "after `core:ready`" contract; a proper two-phase plugin lifecycle is the real fix and is deliberately deferred. +- **BREAKING: flat `options.shortcut` stops working** → third-party tools declaring it silently lose their shortcut. The `[key: string]: unknown` index signature means TypeScript cannot flag the old key. Mitigated by the migration note below; a deprecation shim reading the legacy key was considered and rejected as it would keep the untyped path alive and undermine the point of the change. +- **BREAKING: `name` required on every plugin constructor** → a compile error for any third-party plugin that exposes a `publicApi` or accepts tool options, which is the desired failure mode. A plugin doing neither silently inherits `Function.name` instead; see decision 1 for why that is accepted and what catches it at runtime. +- **Minified class names as registry keys** → a plugin that omits `name` and augments neither map registers under a mangled identifier, and two such plugins can collide non-deterministically across builds. Mitigated by the duplicate-name throw in `PluginRegistry.register()` and by documenting the explicit declaration as mandatory in `docs/plugins.md`. + +## Migration Plan + +1. Land the `sdk` contracts first — they are additive except for `name`, which every in-repo plugin gains in the same commit (`ShortcutsPlugin`, `ClipboardPlugin`, `DOMAdapters`, `CollaborationManager`). +2. Land the core registry and `api.plugins`; nothing reads it yet, so it is inert. +3. Migrate `ShortcutsPlugin` and the three built-in inline tools to `options.plugins.shortcuts` in one commit — the flat key stops being read at that point. +4. Update `docs/plugins.md` and `docs/diagrams/plugin-lifecycle-flow.mmd`. + +Consumer migration is mechanical: add `static readonly name` to plugin classes; move `shortcut: 'X'` under `plugins: { shortcuts: { shortcut: 'X' } }` in tool options or in the `use()` argument. + +Rollback: steps 2–3 revert independently of step 1; the `sdk` additions are inert without a registry. + +## Open Questions + +- Should `Core` expose the plugin registry on the instance itself (`editor.plugins`) in addition to `api.plugins`, for integrators who never touch `EditorAPI`? Deferred until the `@editorjs/editorjs` bundle package settles its public surface. +- Should `PluginRegistry` gate what a plugin may register (e.g. freezing the API object) to stop plugins from mutating each other's surfaces? Not needed for the first cut. diff --git a/openspec/changes/add-plugin-public-api/proposal.md b/openspec/changes/add-plugin-public-api/proposal.md new file mode 100644 index 00000000..e2d19005 --- /dev/null +++ b/openspec/changes/add-plugin-public-api/proposal.md @@ -0,0 +1,34 @@ +## Why + +Plugins registered through `core.use()` are currently write-only participants: they subscribe to the `EventBus` and act on the editor, but nothing they build can be called back — not by the integrator who created the editor, and not by another plugin. At the same time the only way a tool can feed data to a plugin is the untyped `[key: string]: unknown` escape hatch on tool options (`ShortcutsPlugin` reads `tool.options['shortcut']` with no contract, no validation, and no ownership). Both gaps block the plugin ecosystem we want: a plugin cannot ship a callable surface, and a tool cannot declare "here is my configuration for that plugin" in a way either side can type-check. + +## What Changes + +- **New `api.plugins` namespace on `EditorAPI`.** A plugin may expose a public API object; it becomes reachable as `api.plugins.` for consumers (via the editor instance) and for other plugins (via the `api` they already receive). +- **Plugin identity becomes explicit.** `EditorjsPluginConstructor` gains a static `name` string. It keys the registry at runtime and the type maps at compile time. +- **Type safety via declaration merging.** `@editorjs/sdk` declares two empty, augmentable interfaces — `EditorjsPluginApiMap` and `ToolPluginOptionsMap`. A plugin package augments them under its own `name`; every consumer that imports the plugin's types gets full inference with no casts and no imports at the call site. +- **New namespaced `plugins` key in tool options.** Tools declare plugin-directed configuration as `static options = { plugins: { shortcuts: { ... } } }`, typed as `Partial` and merged with `use(Tool, options)` overrides under the same precedence rules as the rest of `options`. +- **Tool facades expose the slice.** `BaseToolFacade` gains an accessor returning a single plugin's merged options slice, so a plugin reads only what is addressed to it. +- **`ShortcutsPlugin` migrates onto both mechanisms** as the proving case: it declares `name = 'shortcuts'`, reads `plugins.shortcuts` instead of the flat `options['shortcut']`, and exposes a public API for registering/unregistering shortcuts at runtime. Built-in tools (`bold`, `italic`, `inline-link`) move their `shortcut` declarations into the namespaced key. +- **`BlocksUI` delegates native `keydown` as a `KeydownUIEvent`.** The event type existed in the SDK and `ShortcutsPlugin` listened for it, but nothing ever dispatched it — so no keyboard shortcut has ever reached a plugin in the running app. `BlocksUI` now dispatches it before its own key handling and skips that handling when a plugin claimed the key via `preventDefault()`. +- **BREAKING**: the flat `options.shortcut` key is no longer read by `ShortcutsPlugin`. Tools declaring shortcuts must move them under `options.plugins.shortcuts`. +- **BREAKING**: `EditorjsPluginConstructor` requires a static `name`. Existing plugin classes must add one. + +## Capabilities + +### New Capabilities +- `plugin-public-api`: how a plugin declares a public API, how the registry keys it by `name`, how `api.plugins` exposes it to integrators and to other plugins, and how the `EditorjsPluginApiMap` augmentation makes access type-safe. +- `tool-plugin-options`: how a tool declares plugin-directed configuration under `options.plugins.`, how it merges with `use()` overrides, how the `ToolPluginOptionsMap` augmentation types it, and how a plugin reads its own slice from a tool facade. + +### Modified Capabilities +- `sdk`: the plugin contract requirement changes — `EditorjsPluginConstructor` gains the static `name`, `EditorjsPlugin` gains the optional public-API declaration, `EditorAPI` gains the `plugins` namespace, and `BaseToolOptions` gains the `plugins` key. +- `ui`: the blocks-holder requirement changes — `BlocksUI` delegates `keydown` as a `KeydownUIEvent` and yields to plugins that claim a key before applying its own undo/redo handling. +- `core`: the keyboard-shortcuts requirement changes — `ShortcutsPlugin` sources shortcuts from `options.plugins.shortcuts` rather than `options.shortcut`, and exposes a runtime registration API. `Core` additionally builds and owns the plugin registry that backs `api.plugins`. + +## Impact + +- **Code**: `packages/sdk/src/entities/EditorjsPlugin.ts`, `BaseTool.ts`, `packages/sdk/src/api/EditorAPI.ts` (new `PluginsAPI.ts`), `packages/sdk/src/tools/facades/BaseToolFacade.ts`; `packages/core/src/index.ts` (plugin instantiation + registry), `packages/core/src/api/` (new `PluginsAPI`), `packages/core/src/plugins/ShortcutsPlugin.ts`; `packages/tools/{bold,italic,inline-link}`; `packages/plugins/clipboard-plugin` and `packages/dom-adapters` gain a `name`; `packages/ui/src/Blocks/Blocks.ts` gains the `KeydownUIEvent` dispatch. +- **APIs**: additive for `EditorAPI`; breaking for plugin constructors (static `name`) and for tools declaring a flat `shortcut`. +- **Ordering**: plugin instantiation currently happens before tools are prepared, and plugins receive the `EditorAPI` in their constructor — the registry must therefore be populated lazily enough that a plugin constructed first can still reach a plugin constructed later. Addressed in design.md. +- **Docs**: `docs/plugins.md` (Registration, EditorAPI, Lifecycle sections) is superseded in part and must be updated; `docs/diagrams/plugin-lifecycle-flow.mmd` gains the registry step. +- **Dependencies**: none added. diff --git a/openspec/changes/add-plugin-public-api/specs/core/spec.md b/openspec/changes/add-plugin-public-api/specs/core/spec.md new file mode 100644 index 00000000..f8d2ab71 --- /dev/null +++ b/openspec/changes/add-plugin-public-api/specs/core/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: Keyboard shortcuts plugin +The system SHALL provide a `ShortcutsPlugin` (an `EditorjsPlugin` with `name` `shortcuts`) that maps keyboard shortcuts declared in a tool's `options.plugins.shortcuts` to inline-tool application through the `EditorAPI`, and that exposes a public API for registering and unregistering shortcuts at runtime. + +#### Scenario: Triggering an inline tool via shortcut +- **GIVEN** an inline tool is registered with `options.plugins.shortcuts.shortcut` set to a key combination (e.g. `CMD+B`) +- **WHEN** that key combination is pressed while the editor has focus +- **THEN** `ShortcutsPlugin` applies the corresponding inline tool to the current selection via the `EditorAPI` + +#### Scenario: Legacy flat shortcut key is ignored +- **GIVEN** a tool declares a flat `options.shortcut` and no `options.plugins.shortcuts` +- **WHEN** that key combination is pressed +- **THEN** no inline tool is applied, since shortcuts are sourced only from the namespaced key + +#### Scenario: Registering a shortcut at runtime +- **GIVEN** an integrator holds `api.plugins.shortcuts` +- **WHEN** a shortcut and handler are registered through that public API +- **THEN** pressing the shortcut invokes the handler, and unregistering it through the same API stops further invocations + +Implemented in `src/plugins/ShortcutsPlugin.ts`. + +## ADDED Requirements + +### Requirement: Plugin registry +`Core` SHALL maintain a registry mapping each registered plugin's `name` to its public API, populate it as plugins are instantiated, and back the `api.plugins` namespace with it. + +#### Scenario: Registry is populated during initialization +- **GIVEN** plugins are registered via `core.use()` +- **WHEN** `initialize()` instantiates them +- **THEN** each plugin exposing a `publicApi` has it registered under its `name` + +#### Scenario: Registration order does not matter +- **GIVEN** plugin A is constructed before plugin B, and A reads `api.plugins.` after initialization completes +- **WHEN** A performs that read +- **THEN** B's public API is available, because `api.plugins` resolves entries at access time rather than capturing a snapshot at construction time + +#### Scenario: Reading a plugin API before it is constructed +- **GIVEN** plugin A reads `api.plugins.` inside its own constructor, before B has been constructed +- **WHEN** that read happens +- **THEN** `undefined` is returned, and the documented contract is that cross-plugin API use belongs after the editor's ready event + +#### Scenario: Conflicting plugin ids are rejected +- **WHEN** two registered plugins declare the same `name` +- **THEN** initialization fails with an error naming the conflicting id + +#### Scenario: Registry is cleared on teardown +- **GIVEN** plugins have been registered and the editor is torn down +- **WHEN** each plugin's `destroy()` runs +- **THEN** its registry entry is removed so no stale public API remains reachable diff --git a/openspec/changes/add-plugin-public-api/specs/plugin-public-api/spec.md b/openspec/changes/add-plugin-public-api/specs/plugin-public-api/spec.md new file mode 100644 index 00000000..4d9d1e17 --- /dev/null +++ b/openspec/changes/add-plugin-public-api/specs/plugin-public-api/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Plugin identity +Every plugin constructor SHALL declare a static `name` string that uniquely identifies the plugin within an editor instance. The `name` SHALL be the key used both by the runtime registry and by the compile-time type maps. + +#### Scenario: Plugin declares its identity +- **WHEN** a class implementing `EditorjsPlugin` is registered via `core.use(MyPlugin)` +- **THEN** its static `name` is used as the registry key for that plugin instance + +#### Scenario: Duplicate plugin id +- **WHEN** two plugins declaring the same `name` are registered on one editor instance +- **THEN** registration throws an error naming the conflicting `name`, rather than silently overwriting the first plugin + +#### Scenario: Plugin without a public API +- **WHEN** a registered plugin declares a `name` but exposes no public API +- **THEN** the plugin is still instantiated normally and `api.plugins[name]` resolves to `undefined` + +#### Scenario: Plugin omits its name declaration +- **GIVEN** a plugin class that does not declare a static `name` and exposes a public API +- **WHEN** it is passed to `core.use()` +- **THEN** compilation fails, because the inherited `Function.name` is a plain `string` and does not narrow to a key of `EditorjsPluginApiMap` + +#### Scenario: Empty plugin name +- **WHEN** a plugin whose `name` resolves to an empty string is registered +- **THEN** registration throws rather than creating an unaddressable registry entry + +### Requirement: Plugin public API declaration +A plugin SHALL be able to expose a public API object by declaring a `publicApi` member on its instance. The core SHALL read that member after the plugin is constructed and register it under the plugin's `name`. + +#### Scenario: Plugin exposes a public API +- **GIVEN** a plugin instance declares a `publicApi` object with callable members +- **WHEN** the editor finishes instantiating plugins +- **THEN** that exact object is retrievable from the plugin registry under the plugin's `name` + +#### Scenario: Public API is not re-created per consumer +- **WHEN** two different consumers read `api.plugins.` +- **THEN** both receive the same object instance, so state held by the plugin API is shared + +### Requirement: Plugins namespace on EditorAPI +The system SHALL expose the registry of plugin public APIs as `api.plugins` on the `EditorAPI` object handed to tools, plugins, and adapters, and on the API surface available to the editor's integrator. + +#### Scenario: Consumer calls a plugin API +- **GIVEN** a plugin with `name` `shortcuts` exposes a public API +- **WHEN** the integrator reads `api.plugins.shortcuts` from the editor instance +- **THEN** the plugin's public API object is returned and its methods act on the live plugin instance + +#### Scenario: Plugin calls another plugin's API +- **GIVEN** plugin A and plugin B are both registered, and B exposes a public API +- **WHEN** plugin A reads `api.plugins.` from the `EditorAPI` it received in its constructor +- **THEN** B's public API is returned regardless of the order in which A and B were registered or constructed + +#### Scenario: Unregistered plugin +- **WHEN** a consumer reads `api.plugins.` for a plugin that was never registered +- **THEN** `undefined` is returned rather than throwing + +### Requirement: Type-safe plugin API access +The `@editorjs/sdk` package SHALL declare an empty, augmentable `EditorjsPluginApiMap` interface keyed by `name`. `api.plugins` SHALL be typed as `Partial` so that a plugin package augmenting the map gives every consumer inferred types without casts. + +#### Scenario: Augmented map yields inference +- **GIVEN** a plugin package augments `EditorjsPluginApiMap` with `{ shortcuts: ShortcutsPluginApi }` +- **WHEN** a consumer whose compilation includes that package's types writes `api.plugins.shortcuts` +- **THEN** the expression is typed as `ShortcutsPluginApi | undefined` with no cast required + +#### Scenario: Access to an unknown id fails to compile +- **GIVEN** no package has augmented `EditorjsPluginApiMap` with the key `unknownPlugin` +- **WHEN** a consumer writes `api.plugins.unknownPlugin` +- **THEN** compilation fails, because the key is absent from the map + +#### Scenario: Declared id must match the augmented key +- **GIVEN** a plugin augments `EditorjsPluginApiMap` under key `shortcuts` +- **WHEN** its static `name` is declared as a different literal +- **THEN** compilation fails, so the runtime key and the type key cannot drift apart diff --git a/openspec/changes/add-plugin-public-api/specs/sdk/spec.md b/openspec/changes/add-plugin-public-api/specs/sdk/spec.md new file mode 100644 index 00000000..bda58315 --- /dev/null +++ b/openspec/changes/add-plugin-public-api/specs/sdk/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: Plugin contracts +The system SHALL define `EditorjsPlugin`/`EditorjsPluginConstructor` (generic UI-plugin contract with optional `destroy()`, an optional `publicApi` member, a static `type`, and a static `name`) and `EditorJSAdapterPlugin`/`EditorjsAdapterPluginConstructor` (singleton adapter plugin contract with `createBlockToolAdapter`/`destroyBlockToolAdapter`). + +#### Scenario: Destroying a plugin +- **GIVEN** a registered `EditorjsPlugin` implements an optional `destroy()` method +- **WHEN** the editor tears down +- **THEN** `destroy()` is called on the plugin instance to release its resources + +#### Scenario: Plugin declares an id and a public API +- **GIVEN** a plugin class declares a static `name` and an instance `publicApi` member +- **WHEN** its type is checked against `EditorjsPluginConstructor` +- **THEN** the `name` literal and the `publicApi` type are both inferred, and a mismatch with the plugin's `EditorjsPluginApiMap` augmentation is a compile error + +Implemented in `src/entities/EditorjsPlugin.ts`, `src/entities/EditorjsAdapterPlugin.ts`, `src/entities/EntityType.ts`. + +### Requirement: EditorAPI surface +The system SHALL expose an `EditorAPI` type aggregating `BlocksAPI`, `SelectionAPI`, `DocumentAPI`, `TextAPI`, and `PluginsAPI` — the API object passed into tools, plugins, and adapters. + +#### Scenario: Tool receives the aggregated API +- **GIVEN** a tool is constructed by the core orchestrator +- **WHEN** its constructor options are built +- **THEN** it receives a single `EditorAPI` object exposing `blocks`, `selection`, `document`, `text`, and `plugins` sub-APIs + +#### Scenario: Plugins namespace is typed by the plugin API map +- **GIVEN** `EditorjsPluginApiMap` has been augmented by one or more plugin packages +- **WHEN** `api.plugins` is accessed +- **THEN** it is typed as `Partial`, so known ids infer their API type and unknown ids fail to compile + +Implemented in `src/api/EditorAPI.ts`, `src/api/{BlocksAPI,DocumentAPI,SelectionAPI,TextAPI,PluginsAPI}.ts`. + +## ADDED Requirements + +### Requirement: Augmentable plugin type maps +The system SHALL declare two empty, augmentable interfaces — `EditorjsPluginApiMap` (plugin id → public API type) and `ToolPluginOptionsMap` (plugin id → tool-directed options type) — that plugin packages extend via module augmentation of `@editorjs/sdk`. + +#### Scenario: Plugin package augments both maps +- **GIVEN** a plugin package declares `declare module '@editorjs/sdk'` augmenting both interfaces under its `name` +- **WHEN** a consumer's compilation includes that package's types +- **THEN** `api.plugins.` and `options.plugins.` are both fully typed with no cast + +#### Scenario: No augmentation present +- **GIVEN** no plugin package has augmented either interface +- **WHEN** `@editorjs/sdk` is compiled on its own +- **THEN** both interfaces are empty and `Partial<...>` of them permits no keys, so the base package remains self-consistent + +### Requirement: Tool options carry plugin-directed configuration +`BaseToolOptions` SHALL include an optional `plugins` key typed as `Partial`, and `BaseToolFacade` SHALL expose an accessor returning the merged slice for a single plugin id. + +#### Scenario: Facade merges plugin options +- **GIVEN** a tool's `static options` and the second argument of `use()` both carry a `plugins` key +- **WHEN** the facade's plugin-options accessor is called with a plugin id +- **THEN** it returns that id's configuration with `use()` values taking precedence over the static ones + +Implemented in `src/entities/BaseTool.ts`, `src/tools/facades/BaseToolFacade.ts`. diff --git a/openspec/changes/add-plugin-public-api/specs/tool-plugin-options/spec.md b/openspec/changes/add-plugin-public-api/specs/tool-plugin-options/spec.md new file mode 100644 index 00000000..d188aee7 --- /dev/null +++ b/openspec/changes/add-plugin-public-api/specs/tool-plugin-options/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: Namespaced plugin options on tools +Tool options SHALL include a `plugins` key whose sub-keys are plugin ids, each holding the configuration that tool addresses to that plugin. The key SHALL be available both in a tool's `static options` and in the second argument of `core.use(Tool, options)`. + +#### Scenario: Tool declares plugin-directed configuration +- **GIVEN** a tool declares `static options = { plugins: { shortcuts: { shortcut: 'CMD+B' } } }` +- **WHEN** the tool is prepared +- **THEN** the `shortcuts` plugin can read `{ shortcut: 'CMD+B' }` as that tool's configuration for it + +#### Scenario: Tool declares nothing for a plugin +- **GIVEN** a tool declares no `plugins` key, or omits a given plugin id from it +- **WHEN** a plugin reads its slice for that tool +- **THEN** `undefined` is returned and the plugin takes no action for that tool + +#### Scenario: Plugin options do not collide with core option keys +- **GIVEN** a plugin id equal to an existing tool option name such as `toolbox` +- **WHEN** a tool declares configuration for that plugin under `plugins` +- **THEN** the core option of the same name is unaffected, because plugin configuration lives in its own namespace + +### Requirement: Merging of plugin options +Plugin-directed options SHALL merge per plugin id, with values supplied in `core.use(Tool, options)` overriding the tool's `static options`. Plugin ids present in only one of the two sources SHALL be preserved. + +#### Scenario: Integrator overrides a tool's plugin configuration +- **GIVEN** a tool declares `static options = { plugins: { shortcuts: { shortcut: 'CMD+B' } } }` +- **WHEN** it is registered as `core.use(Tool, { plugins: { shortcuts: { shortcut: 'CMD+SHIFT+B' } } })` +- **THEN** the `shortcuts` plugin reads `CMD+SHIFT+B` for that tool + +#### Scenario: Disjoint plugin ids are preserved +- **GIVEN** a tool's `static options` configure plugin `a` and the `use()` argument configures plugin `b` +- **WHEN** the merged options are computed +- **THEN** both `a` and `b` configurations are present + +#### Scenario: Override replaces a plugin's slice +- **GIVEN** a tool's `static options` configure plugin `a` with two keys +- **WHEN** the `use()` argument supplies a configuration for plugin `a` containing only one of them +- **THEN** the slice supplied through `use()` wins for that plugin id, matching the precedence of the surrounding tool options + +### Requirement: Plugin reads its own slice from a tool facade +A tool facade SHALL expose an accessor returning the merged plugin-options slice for a single plugin id, so a plugin reads only the configuration addressed to it and never inspects raw option keys. + +#### Scenario: Plugin reads a tool's configuration for itself +- **GIVEN** a plugin observes a tool-loaded event carrying a tool facade +- **WHEN** it requests its own slice by its `name` +- **THEN** it receives the merged configuration for that plugin id only, without access to other plugins' slices + +### Requirement: Type-safe plugin options +The `@editorjs/sdk` package SHALL declare an empty, augmentable `ToolPluginOptionsMap` interface keyed by `name`. The `plugins` key on tool options SHALL be typed as `Partial`, and the facade accessor SHALL return the mapped type for the requested id. + +#### Scenario: Augmented map types a tool's declaration +- **GIVEN** a plugin package augments `ToolPluginOptionsMap` with `{ shortcuts: { shortcut: string } }` +- **WHEN** a tool declares `plugins: { shortcuts: { shortcut: 42 } }` +- **THEN** compilation fails on the type of `shortcut` + +#### Scenario: Facade accessor is typed by id +- **GIVEN** `ToolPluginOptionsMap` is augmented with `{ shortcuts: ShortcutsToolOptions }` +- **WHEN** a plugin requests the slice for id `shortcuts` +- **THEN** the result is typed as `ShortcutsToolOptions | undefined` with no cast required + +#### Scenario: Configuration for an unknown plugin in the use() argument fails to compile +- **GIVEN** no package has augmented `ToolPluginOptionsMap` with the key `unknownPlugin` +- **WHEN** an integrator calls `core.use(Tool, { plugins: { unknownPlugin: { ... } } })` +- **THEN** compilation fails, because the argument is a fresh object literal checked against `Partial` + +#### Scenario: A tool's static options are checked only when the author opts in +- **GIVEN** a tool declares `static options` with a `plugins` key +- **WHEN** the declaration carries no contextual type, or the tool's compilation includes no augmentation of `ToolPluginOptionsMap` +- **THEN** no compile error is raised for an unknown plugin id, because TypeScript excess-property-checks only fresh literals against a non-empty target +- **AND** adding `satisfies` together with the plugin package's types makes the same declaration fail to compile + +#### Scenario: Registering a tool does not validate its declared plugin ids +- **GIVEN** a tool whose `static options.plugins` addresses an id absent from `ToolPluginOptionsMap` +- **WHEN** it is passed to `core.use(Tool)` +- **THEN** registration compiles, because a constructor is checked by structural assignability where extra properties are legal, and an unrecognised slice is ignored at runtime diff --git a/openspec/changes/add-plugin-public-api/specs/ui/spec.md b/openspec/changes/add-plugin-public-api/specs/ui/spec.md new file mode 100644 index 00000000..3609a3f5 --- /dev/null +++ b/openspec/changes/add-plugin-public-api/specs/ui/spec.md @@ -0,0 +1,46 @@ +## MODIFIED Requirements + +### Requirement: Blocks holder rendering and input capture +The system SHALL provide `BlocksUI`, which renders the contenteditable blocks holder, adds/removes block wrappers on `core:BlockAdded`/`core:BlockRemoved`, captures native `beforeinput` and remaps it into a normalized `BeforeInputUIEvent`, delegates native `keydown` as a `KeydownUIEvent` so plugins can claim keyboard shortcuts, handles undo/redo keyboard shortcuts for keys no plugin claimed, and dispatches block-hover selection events. + +#### Scenario: Inserting a block wrapper at an index +- **GIVEN** a `BlockAddedCoreEvent` with a valid index +- **WHEN** `BlocksUI` processes it +- **THEN** the block element is wrapped and inserted at that position in the blocks holder, or appended if the index is beyond the current list + +#### Scenario: Rejecting an invalid block index +- **GIVEN** a `BlockAddedCoreEvent`/`BlockRemovedCoreEvent` with an out-of-bounds index +- **WHEN** `BlocksUI` processes it +- **THEN** it throws an "Index out of bounds" error + +#### Scenario: Hovering a block dispatches selection +- **GIVEN** the pointer enters a rendered block element +- **WHEN** the `mouseenter` event fires +- **THEN** `BlocksUI` dispatches a `BlockSelectedUIEvent` carrying the block and its index + +#### Scenario: Normalizing beforeinput +- **GIVEN** a native `beforeinput` event fires on the blocks holder +- **WHEN** `BlocksUI` intercepts it +- **THEN** the default action is prevented and a `BeforeInputUIEvent` is dispatched carrying `data`, `inputType`, `isComposing`, and `targetRanges`, distinguishing native-input vs. contenteditable sources and cross-input selections + +#### Scenario: Delegating native keydown events +- **GIVEN** a native `keydown` event fires on the blocks holder +- **WHEN** `BlocksUI` intercepts it +- **THEN** it dispatches a `KeydownUIEvent` on the `EventBus` carrying the native event as `nativeEvent`, before any of its own key handling + +#### Scenario: A plugin claims a keyboard shortcut +- **GIVEN** a plugin listening for `KeydownUIEvent` calls `preventDefault()` on the native event +- **WHEN** the dispatch returns +- **THEN** `BlocksUI` performs no further handling for that key, so a plugin-registered shortcut takes precedence over the built-in handling + +#### Scenario: Undo/redo keyboard shortcuts +- **GIVEN** the blocks holder has focus and no plugin claimed the key +- **WHEN** Cmd/Ctrl+Z is pressed +- **THEN** `api.document.undo()` is called with the default action prevented; if Shift is also held, `api.document.redo()` is called instead + +#### Scenario: Delegating native copy events +- **GIVEN** a native `copy` event fires on the blocks holder +- **WHEN** `BlocksUI` intercepts it +- **THEN** it dispatches a `CopyUIEvent` on the `EventBus` carrying the native event as `nativeEvent`, without calling `preventDefault` itself + +Implemented in `src/Blocks/Blocks.ts`, `src/Blocks/events/*`. diff --git a/openspec/changes/add-plugin-public-api/tasks.md b/openspec/changes/add-plugin-public-api/tasks.md new file mode 100644 index 00000000..f683d127 --- /dev/null +++ b/openspec/changes/add-plugin-public-api/tasks.md @@ -0,0 +1,56 @@ +## 1. SDK contracts + +- [x] 1.1 Add `EditorjsPluginApiMap` and `ToolPluginOptionsMap` empty augmentable interfaces plus the `PluginId`, `PluginsAPI` and `ToolPluginOptions` helper types in `packages/sdk/src/entities/EditorjsPlugin.ts` (or a new `PluginRegistry.ts`), and export them from `entities/index.ts` +- [x] 1.2 Add a type-level test file asserting that an augmented map yields inference (`api.plugins.x` typed, unknown key errors, `Partial` makes entries optional) — use `@ts-expect-error` for the negative cases +- [x] 1.3 Extend `EditorjsPlugin` with the optional `publicApi` member keyed off `Id`, and `EditorjsPluginConstructor` with the static `name: Id`, per design decision 3 +- [x] 1.4 Add type-level tests for the `Function.name` shadow: a plugin declaring `static readonly name = 'x'` infers the literal, while one omitting the declaration widens `Id` to `string` and makes a declared `publicApi` a compile error +- [x] 1.5 Add `PluginsAPI` to `packages/sdk/src/api/` and the `plugins` member to the `EditorAPI` interface; export from `api/index.ts` +- [x] 1.6 Add the optional `plugins?: ToolPluginOptions` key to `BaseToolOptions` in `packages/sdk/src/entities/BaseTool.ts` +- [x] 1.7 Write failing specs in `packages/sdk/src/tools/facades/BaseToolFacade.spec.ts` for per-id merging (use() wins for a shared id, disjoint ids preserved, slice replaced not deep-merged, `undefined` when absent) +- [x] 1.8 Implement `BaseToolFacade.pluginOptions(id)` and update the `options` getter to merge `plugins` per id so both agree +- [x] 1.9 Give `EditorJSAdapterPlugin`'s constructor contract a `name` as well, so adapters satisfy the same interface + +## 2. Core plugin registry + +- [x] 2.1 Write failing specs for `PluginRegistry`: registers a public API under its id, returns `undefined` for unknown ids, throws on duplicate id, `unregister` removes the entry, and the exposed record is the same object across reads +- [x] 2.2 Implement `packages/core/src/components/PluginRegistry.ts` holding the shared mutable record, per design decision 4 +- [x] 2.3 Bind `PluginRegistry` in the IoC container and add a `plugins` getter to `packages/core/src/api/index.ts` (`EditorAPI`) delegating to it +- [x] 2.4 Write a failing integration spec: two plugins registered in either order, the first can read the second's public API after `core:ready`, and reading during construction yields `undefined` +- [x] 2.5 Instantiate the registry before plugin initialization in `Core`, and register each plugin's `publicApi` in `#initializePlugin()` right after construction +- [x] 2.6 Narrow the `use()` plugin overload to `EditorjsPluginConstructor` so a `name` that drifts from the augmented map key fails to compile +- [x] 2.7 Add `static readonly name` to `ClipboardPlugin`, `DOMAdapters`, `CollaborationManager` and any other in-repo plugin, and fix resulting type errors + +## 3. ShortcutsPlugin migration + +- [x] 3.1 Write failing specs for the shortcuts public API: `register` makes a keypress invoke the handler, `unregister` stops it, and re-registering the same shortcut replaces the previous handler +- [x] 3.2 Write a failing spec asserting a tool declaring `options.plugins.shortcuts.shortcut` triggers its inline tool on keypress, and that a tool declaring only the legacy flat `options.shortcut` does not +- [x] 3.3 Define `ShortcutsToolOptions` / `ShortcutsPluginApi` and augment both SDK maps under the `shortcuts` id from `packages/core/src/plugins/ShortcutsPlugin.ts` +- [x] 3.4 Add `name`, expose `publicApi`, and route tool-declared shortcuts through the same internal table as API-registered ones +- [x] 3.5 Replace the `tool.options['shortcut']` read with `facade.pluginOptions('shortcuts')` and drop the untyped access +- [x] 3.6 Clear registry state and unregister the plugin in `destroy()` + +## 4. Built-in tools + +- [x] 4.1 Move `shortcut` under `plugins: { shortcuts: { shortcut: … } }` in `packages/tools/bold`, `packages/tools/italic`, and `packages/tools/inline-link` +- [x] 4.2 Update each tool's specs to the new option shape +- [x] 4.3 Verify shortcuts still work end to end in `packages/playground` — required adding the missing keydown producer first (group 6) + +## 6. Keydown producer + +- [x] 6.1 Dispatch `KeydownUIEvent` from the `BlocksUI` keydown listener in `packages/ui/src/Blocks/Blocks.ts` — the event had no producer, so no shortcut ever reached `ShortcutsPlugin` +- [x] 6.2 Skip `BlocksUI`'s own undo/redo handling when a plugin claimed the key via `preventDefault()` +- [x] 6.3 Verify in the playground that CMD+B and CMD+I apply formatting through the new tool-options path, and confirm against a stashed baseline that undo behaviour is unchanged + +## 7. TypeScript caveats documentation + +- [x] 7.1 Prototype a `use()`-site guard rejecting unknown plugin ids; verified it catches a real tool's bogus key but false-positives on valid config when the calling package lacks the augmentation — reverted, rationale recorded in `docs/plugins.md` +- [x] 7.2 Document the augmentation-visibility rules for `api.plugins`: the three type-only import forms, the package-entry-point requirement, and the global-map vs per-instance-registry caveat +- [x] 7.3 Document why a tool's `static options.plugins` is unchecked, where the check does fire (`use()` second argument), and the opt-in `satisfies` + type-dependency recipe +- [x] 7.4 Correct the `tool-plugin-options` spec scenarios to state where the compile-time check actually fires + +## 5. Docs and validation + +- [x] 5.1 Update `docs/plugins.md`: `name`, `api.plugins`, the `plugins` options key, the "after `core:ready`" cross-plugin contract, and the global-type-map caveat +- [x] 5.2 Update `docs/diagrams/plugin-lifecycle-flow.mmd` with the registry population step +- [x] 5.3 Add a migration note for the two breaking changes (flat `shortcut` key, required `name`) +- [x] 5.4 Run `yarn lint` and `yarn test` across affected workspaces and fix fallout diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index b3775ac2..75d8b82d 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -34,6 +34,11 @@ export class CollaborationManager implements EditorjsPlugin { */ public static readonly type = PluginType.Plugin; + /** + * Plugin name used to identify the plugin across the editor + */ + public static readonly name = 'collaboration'; + /** * Editor API instance used to interact with the document */ diff --git a/packages/collaboration-manager/test/mocks/createManager.ts b/packages/collaboration-manager/test/mocks/createManager.ts index cd9ab1f1..e2b1478c 100644 --- a/packages/collaboration-manager/test/mocks/createManager.ts +++ b/packages/collaboration-manager/test/mocks/createManager.ts @@ -59,6 +59,7 @@ export function createManager(config: CoreConfigValidated, model: EditorModel): selection: {} as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any text: {} as any, + plugins: {}, }; const manager = new CollaborationManager({ diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 3909cc60..243c5385 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -5,6 +5,8 @@ import { BlocksAPI } from './BlocksAPI.js'; import { SelectionAPI } from './SelectionAPI.js'; import { DocumentAPI } from './DocumentAPI/index.js'; import { TextAPI } from './TextAPI.js'; +import { PluginRegistry } from '../components/PluginRegistry.js'; +import type { PluginsAPI } from '@editorjs/sdk'; /** * Class gathers all Editor's APIs @@ -34,4 +36,20 @@ export class EditorAPI implements EditorApiInterface { */ @inject(TextAPI) public text!: TextAPI; + + /** + * Registry holding the public APIs exposed by the registered plugins + */ + @inject(PluginRegistry) + private readonly pluginRegistry!: PluginRegistry; + + /** + * Public APIs exposed by the registered plugins, keyed by plugin `name`. + * + * Exposed as a getter so the registry is resolved on access: plugins receive this API in their + * constructor, before the plugins registered after them exist. + */ + public get plugins(): PluginsAPI { + return this.pluginRegistry.api; + } } diff --git a/packages/core/src/components/PluginRegistry.integration.spec.ts b/packages/core/src/components/PluginRegistry.integration.spec.ts new file mode 100644 index 00000000..a23ea4e8 --- /dev/null +++ b/packages/core/src/components/PluginRegistry.integration.spec.ts @@ -0,0 +1,140 @@ +/* eslint-disable jsdoc/require-jsdoc */ + +import { describe, expect, it } from '@jest/globals'; +import type { EditorAPI, EditorjsPluginParams } from '@editorjs/sdk'; +import { PluginRegistry } from './PluginRegistry.js'; + +/** + * Public API the second plugin exposes + */ +interface LateProbeApi { + /** + * Arbitrary method the earlier plugin calls once everything is initialized + */ + answer(): number; +} + +declare module '@editorjs/sdk' { + interface EditorjsPluginApiMap { + /** + * Public API of the plugin constructed second + */ + lateProbe: LateProbeApi; + } +} + +const ANSWER = 42; + +/** + * Reproduces how `Core.#initializePlugin` hands out the API and fills the registry: every plugin + * receives the same API object at construction time, and its public API is registered right after. + * + * The API object mirrors `EditorAPI.plugins`, which is a getter delegating to the registry — + * that getter, plus the registry's single shared record, is what makes registration order + * irrelevant. Booting a full `Core` is not possible here: it needs a real DOM. + * @param plugins - plugin constructors, in registration order + */ +function initializePlugins( + plugins: { name: string; + ctor: new (params: EditorjsPluginParams) => object; }[] +): { instances: object[]; + api: EditorAPI; } { + const registry = new PluginRegistry(); + const api = { + get plugins() { + return registry.api; + }, + } as EditorAPI; + + const instances = plugins.map(({ name, ctor }) => { + const instance = new ctor({ api } as EditorjsPluginParams); + const { publicApi } = instance as { publicApi?: unknown }; + + if (publicApi !== undefined) { + registry.register(name, publicApi); + } + + return instance; + }); + + return { + instances, + api, + }; +} + +/** + * Plugin constructed first; it grabs the API object and reads from it both immediately and later + */ +class EarlyPlugin { + public readonly api: EditorAPI; + + public readonly seenDuringConstruction: LateProbeApi | undefined; + + constructor(params: EditorjsPluginParams) { + this.api = params.api; + this.seenDuringConstruction = params.api.plugins.lateProbe; + } + + /** + * Reads the other plugin's API the way a handler running after `core:ready` would + */ + public readLater(): LateProbeApi | undefined { + return this.api.plugins.lateProbe; + } +} + +/** + * Plugin constructed second, exposing the API the earlier plugin wants + */ +class LatePlugin { + public readonly publicApi: LateProbeApi = { answer: () => ANSWER }; + + constructor(_params: EditorjsPluginParams) {} +} + +describe('PluginRegistry (integration with the API object handed to plugins)', () => { + it('should expose a later plugin API to a plugin constructed before it', () => { + const { instances } = initializePlugins([ + { name: 'early', + ctor: EarlyPlugin }, + { name: 'lateProbe', + ctor: LatePlugin }, + ]); + const [early] = instances as [EarlyPlugin]; + + expect(early.readLater()?.answer()).toBe(ANSWER); + }); + + it('should yield undefined when a plugin reads another plugin API during construction', () => { + const { instances } = initializePlugins([ + { name: 'early', + ctor: EarlyPlugin }, + { name: 'lateProbe', + ctor: LatePlugin }, + ]); + const [early] = instances as [EarlyPlugin]; + + expect(early.seenDuringConstruction).toBeUndefined(); + }); + + it('should expose a plugin API regardless of the order the plugins were registered in', () => { + const reversed = initializePlugins([ + { name: 'lateProbe', + ctor: LatePlugin }, + { name: 'early', + ctor: EarlyPlugin }, + ]); + + expect(reversed.api.plugins.lateProbe?.answer()).toBe(ANSWER); + }); + + it('should fail initialization when two plugins share a name', () => { + expect(() => initializePlugins([ + { name: 'lateProbe', + ctor: LatePlugin }, + { name: 'lateProbe', + ctor: LatePlugin }, + ])).toThrow(/lateProbe/); + }); +}); diff --git a/packages/core/src/components/PluginRegistry.spec.ts b/packages/core/src/components/PluginRegistry.spec.ts new file mode 100644 index 00000000..3284c3c1 --- /dev/null +++ b/packages/core/src/components/PluginRegistry.spec.ts @@ -0,0 +1,102 @@ +/* eslint-disable jsdoc/require-jsdoc */ + +import { describe, expect, it } from '@jest/globals'; +import type { PluginId } from '@editorjs/sdk'; +import { PluginRegistry } from './PluginRegistry.js'; + +/** + * Public API a fake plugin exposes + */ +interface RegistryProbeApi { + /** + * Arbitrary method used to check the registered object is handed back untouched + */ + ping(): string; +} + +declare module '@editorjs/sdk' { + interface EditorjsPluginApiMap { + /** + * Fake plugin's public API + */ + registryProbe: RegistryProbeApi; + } +} + +describe('PluginRegistry', () => { + it('should expose a registered public API under the plugin name', () => { + const registry = new PluginRegistry(); + const publicApi: RegistryProbeApi = { ping: () => 'pong' }; + + registry.register('registryProbe', publicApi); + + expect(registry.api.registryProbe).toBe(publicApi); + }); + + it('should return undefined for a plugin that was never registered', () => { + const registry = new PluginRegistry(); + + expect(registry.api.registryProbe).toBeUndefined(); + }); + + it('should return the same record object across reads so late writes stay visible', () => { + const registry = new PluginRegistry(); + const captured = registry.api; + + registry.register('registryProbe', { ping: () => 'pong' }); + + expect(captured).toBe(registry.api); + expect(captured.registryProbe).toBeDefined(); + }); + + it('should throw when two plugins register under the same name', () => { + const registry = new PluginRegistry(); + + registry.register('registryProbe', { ping: () => 'first' }); + + expect(() => registry.register('registryProbe', { ping: () => 'second' })) + .toThrow(/registryProbe/); + }); + + it('should throw when a plugin name is empty', () => { + const registry = new PluginRegistry(); + + expect(() => registry.register('', { ping: () => 'pong' })).toThrow(); + }); + + it('should remove the entry on unregister', () => { + const registry = new PluginRegistry(); + + registry.register('registryProbe', { ping: () => 'pong' }); + registry.unregister('registryProbe'); + + expect(registry.api.registryProbe).toBeUndefined(); + }); + + it('should allow registering a name again after it was unregistered', () => { + const registry = new PluginRegistry(); + + registry.register('registryProbe', { ping: () => 'first' }); + registry.unregister('registryProbe'); + + expect(() => registry.register('registryProbe', { ping: () => 'second' })).not.toThrow(); + }); + + it('should not report a name inherited from Object.prototype as already registered', () => { + const registry = new PluginRegistry(); + const publicApi = { ping: () => 'pong' }; + + expect(() => registry.register('toString' as PluginId, publicApi)).not.toThrow(); + expect((registry.api as Record).toString).toBe(publicApi); + }); + + it('should store a "__proto__" name as a plain entry instead of touching the prototype', () => { + const registry = new PluginRegistry(); + const publicApi = { ping: () => 'pong' }; + + registry.register('__proto__' as PluginId, publicApi); + + expect(Object.getOwnPropertyDescriptor(registry.api, '__proto__')?.value).toBe(publicApi); + expect(({}).hasOwnProperty('ping')).toBe(false); + }); +}); diff --git a/packages/core/src/components/PluginRegistry.ts b/packages/core/src/components/PluginRegistry.ts new file mode 100644 index 00000000..d13f00e4 --- /dev/null +++ b/packages/core/src/components/PluginRegistry.ts @@ -0,0 +1,56 @@ +import 'reflect-metadata'; +import { injectable } from 'inversify'; +import type { PluginId, PluginsAPI } from '@editorjs/sdk'; + +/** + * Holds the public APIs exposed by the registered plugins, keyed by plugin `name`. + * + * The record handed out by {@link api} is created once and mutated in place as plugins are + * constructed. Plugins receive the `EditorAPI` in their constructor — before later plugins exist — + * so handing out a snapshot would permanently hide anything registered afterwards. Sharing one + * object means a read performed at call time (after `core:ready`) always sees the full registry. + */ +@injectable() +export class PluginRegistry { + /** + * The shared record backing `api.plugins`. + * + * Created without a prototype so plugin names are plain string keys: nothing is inherited from + * `Object.prototype`, and a name like `__proto__` or `toString` is stored as data rather than + * hitting an accessor or shadowing a built-in. + */ + readonly #record = Object.create(null) as Record; + + /** + * Registry of plugin public APIs, as exposed through `api.plugins` + */ + public get api(): PluginsAPI { + return this.#record as PluginsAPI; + } + + /** + * Adds a plugin's public API to the registry + * @param name - plugin's `name`, used as the registry key + * @param publicApi - the API object the plugin exposes + * @throws When the name is empty or already taken by another plugin + */ + public register(name: PluginId, publicApi: unknown): void { + if (name === '') { + throw new Error('Editor.js plugin must have a non-empty "name" to expose a public API'); + } + + if (Object.prototype.hasOwnProperty.call(this.#record, name)) { + throw new Error(`Editor.js plugin with name "${name}" is already registered. Plugin names must be unique`); + } + + this.#record[name] = publicApi; + } + + /** + * Removes a plugin's public API from the registry, so no stale API stays reachable + * @param name - key the plugin's API was registered under + */ + public unregister(name: PluginId): void { + delete this.#record[name]; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4f9d0c7d..cc4a313f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,8 @@ import { type InlineToolConstructor, PluginType, ToolType, - type ToolStaticOptions + type ToolStaticOptions, + type PluginId } from '@editorjs/sdk'; import { composeDataFromVersion2 } from './utils/composeDataFromVersion2.js'; import ToolsManager from './tools/ToolsManager.js'; @@ -30,6 +31,7 @@ import { BlockRenderer } from './components/BlockRenderer.js'; import { SelectionManager } from './components/SelectionManager.js'; import { TOKENS } from './tokens.js'; import { UndoRedoManager } from './components/UndoRedoManager.js'; +import { PluginRegistry } from './components/PluginRegistry.js'; import { ClipboardPlugin } from '@editorjs/clipboard-plugin'; /** @@ -128,9 +130,13 @@ export default class Core { public use(tool: ToolConstructable, options?: ToolStaticOptions): Core; /** * Injects Plugin into the container to initialize on Editor's init + * + * The plugin's id is inferred from its static `name`, which types its `publicApi` against + * `EditorjsPluginApiMap`. A plugin that omits the declaration widens the id to `string`, + * making any declared `publicApi` a compile error here. * @param plugin - allows to pass any implementation of editor plugins */ - public use(plugin: EditorjsPluginConstructor | EditorjsAdapterPluginConstructor): Core; + public use(plugin: EditorjsPluginConstructor | EditorjsAdapterPluginConstructor): Core; /** * Overloaded method to register Editor.js Plugins/Tools/etc * @param pluginOrTool - entity to register @@ -222,18 +228,22 @@ export default class Core { } /** - * Create instance of plugin + * Create instance of plugin and register the public API it exposes * @param plugin - Plugin constructor to initialize */ #initializePlugin(plugin: EditorjsPluginConstructor): void { const eventBus = this.#iocContainer.get(EventBus); const apiFactory = this.#iocContainer.get>(TOKENS.EditorAPIFactory) as () => EditorAPI; - new plugin({ + const instance = new plugin({ config: this.#config, api: apiFactory(), eventBus, }); + + if (instance.publicApi !== undefined) { + this.#iocContainer.get(PluginRegistry).register(plugin.name, instance.publicApi); + } } /** diff --git a/packages/core/src/plugins/ShortcutsPlugin.spec.ts b/packages/core/src/plugins/ShortcutsPlugin.spec.ts new file mode 100644 index 00000000..b3cf690e --- /dev/null +++ b/packages/core/src/plugins/ShortcutsPlugin.spec.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { EditorAPI, EditorjsPluginParams, ToolLoadedCoreEvent } from '@editorjs/sdk'; +import { CoreEventBase, CoreEventType, EventBus, KeydownUIEventName, UIEventBase } from '@editorjs/sdk'; +import { ShortcutsPlugin } from './ShortcutsPlugin.js'; + +const applyInlineTool = jest.fn(); + +/** + * Editor API stub exposing only what the plugin touches + */ +const api = { + selection: { applyInlineTool }, +} as unknown as EditorAPI; + +/** + * Builds a tool facade stub whose `pluginOptions` answers for the shortcuts plugin only + * @param name - identifier the plugin applies the inline tool by + * @param shortcut - shortcut declared under `options.plugins.shortcuts` + * @param legacyShortcut - shortcut declared under the removed flat `options.shortcut` key + */ +function createToolFacade( + name: string, + shortcut: string | undefined, + legacyShortcut?: string +): ToolLoadedCoreEvent['detail']['tool'] { + return { + name, + options: legacyShortcut === undefined ? {} : { shortcut: legacyShortcut }, + pluginOptions: (id: string) => (id === 'shortcuts' && shortcut !== undefined ? { shortcut } : undefined), + } as unknown as ToolLoadedCoreEvent['detail']['tool']; +} + +/** + * Dispatches a keydown UI event carrying a stub native event. + * The test environment is `node`, which has no `KeyboardEvent`, so only the fields the + * shortcut matcher reads are provided. + * @param eventBus - bus the plugin listens on + * @param init - native keyboard event properties + */ +function pressKey(eventBus: EventBus, init: Partial): void { + const nativeEvent = { + code: '', + key: '', + altKey: false, + shiftKey: false, + metaKey: false, + ctrlKey: false, + repeat: false, + isComposing: false, + preventDefault: jest.fn(), + ...init, + } as unknown as KeyboardEvent; + + eventBus.dispatchEvent( + new UIEventBase(KeydownUIEventName, { nativeEvent }) as unknown as Event + ); +} + +describe('ShortcutsPlugin', () => { + let eventBus: EventBus; + let plugin: ShortcutsPlugin; + + beforeEach(() => { + applyInlineTool.mockReset(); + eventBus = new EventBus(); + plugin = new ShortcutsPlugin({ + api, + eventBus, + config: {}, + } as unknown as EditorjsPluginParams); + }); + + describe('tool-declared shortcuts', () => { + it('should apply the inline tool declared under options.plugins.shortcuts', () => { + eventBus.dispatchEvent(new CoreEventBase(CoreEventType.ToolLoaded, { + tool: createToolFacade('bold', 'CMD+B'), + }) as unknown as Event); + + pressKey(eventBus, { code: 'KeyB', + metaKey: true }); + + expect(applyInlineTool).toHaveBeenCalledWith({ tool: 'bold' }); + }); + + it('should ignore a shortcut declared under the legacy flat options.shortcut key', () => { + eventBus.dispatchEvent(new CoreEventBase(CoreEventType.ToolLoaded, { + tool: createToolFacade('bold', undefined, 'CMD+B'), + }) as unknown as Event); + + pressKey(eventBus, { code: 'KeyB', + metaKey: true }); + + expect(applyInlineTool).not.toHaveBeenCalled(); + }); + }); + + describe('public API', () => { + it('should invoke a handler registered through the public API', () => { + const handler = jest.fn(); + + plugin.publicApi.register('CMD+K', handler); + + pressKey(eventBus, { code: 'KeyK', + metaKey: true }); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should stop invoking a handler after it is unregistered', () => { + const handler = jest.fn(); + + plugin.publicApi.register('CMD+K', handler); + plugin.publicApi.unregister('CMD+K'); + + pressKey(eventBus, { code: 'KeyK', + metaKey: true }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should replace the previous handler when the same shortcut is registered again', () => { + const first = jest.fn(); + const second = jest.fn(); + + plugin.publicApi.register('CMD+K', first); + plugin.publicApi.register('CMD+K', second); + + pressKey(eventBus, { code: 'KeyK', + metaKey: true }); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('should stop invoking handlers after the plugin is destroyed', () => { + const handler = jest.fn(); + + plugin.publicApi.register('CMD+K', handler); + plugin.destroy(); + + pressKey(eventBus, { code: 'KeyK', + metaKey: true }); + + expect(handler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/plugins/ShortcutsPlugin.ts b/packages/core/src/plugins/ShortcutsPlugin.ts index e7c6269c..7b1c83b1 100644 --- a/packages/core/src/plugins/ShortcutsPlugin.ts +++ b/packages/core/src/plugins/ShortcutsPlugin.ts @@ -16,26 +16,104 @@ import { } from '@editorjs/sdk'; /** - * Subscribes to tool-loaded events and registers keyboard shortcuts from merged tool `options` - * (`shortcut` for inline tools; `shortcuts` map reserved for block tools / render overrides). - * Applies formatting via `api.selection.applyInlineTool`. + * Handler invoked when a registered shortcut is pressed */ -export class ShortcutsPlugin implements EditorjsPlugin { +export type ShortcutHandler = (event: KeyboardEvent) => void; + +/** + * Configuration a tool addresses to the Shortcuts plugin under `options.plugins.shortcuts` + */ +export interface ShortcutsToolOptions { + /** + * Keyboard shortcut string (Editor.js codex notation, e.g. `CMD+B`) that applies the tool + */ + shortcut?: string; +} + +/** + * Public API the Shortcuts plugin exposes as `api.plugins.shortcuts` + */ +export interface ShortcutsPluginApi { + /** + * Binds a handler to a keyboard shortcut, replacing whatever was bound to it before + * @param shortcut - shortcut string in Editor.js codex notation, e.g. `CMD+K` + * @param handler - called with the native event when the shortcut is pressed + */ + register(shortcut: string, handler: ShortcutHandler): void; + + /** + * Removes whatever handler is bound to the given shortcut + * @param shortcut - shortcut string in Editor.js codex notation + */ + unregister(shortcut: string): void; +} + +declare module '@editorjs/sdk' { + /* eslint-disable jsdoc/require-jsdoc -- interface members are documented on the types they alias */ + interface EditorjsPluginApiMap { + /** + * Shortcuts plugin's public API + */ + shortcuts: ShortcutsPluginApi; + } + + interface ToolPluginOptionsMap { + /** + * Options tools address to the Shortcuts plugin + */ + shortcuts: ShortcutsToolOptions; + } + /* eslint-enable jsdoc/require-jsdoc */ +} + +/** + * Subscribes to tool-loaded events and registers keyboard shortcuts declared by tools under + * `options.plugins.shortcuts`, and exposes {@link ShortcutsPluginApi} for registering shortcuts + * at runtime. Tool-declared and API-registered shortcuts share one table, so one shortcut always + * resolves to exactly one handler. + */ +export class ShortcutsPlugin implements EditorjsPlugin<'shortcuts'> { /** * Registers with `core.use` under {@link PluginType.Plugin} (same id as Typedi multi-registration). */ public static readonly type = PluginType.Plugin; /** - * Shortcut string (Editor.js codex) → inline tool name from config (e.g. `bold`). + * Plugin name — keys both `api.plugins.shortcuts` and `options.plugins.shortcuts`. */ - readonly #shortcutToToolName = new Map(); + public static readonly name = 'shortcuts'; + + /** + * Shortcut string (Editor.js codex) → handler invoked when it is pressed. + */ + readonly #handlers = new Map(); /** * API instance */ readonly #api: EditorAPI; + /** + * API exposed to the integrator and to other plugins + */ + public readonly publicApi: ShortcutsPluginApi = { + /** + * Binds a handler to a shortcut, replacing any handler bound to it before + * @param shortcut - shortcut string in Editor.js codex notation + * @param handler - called with the native event when the shortcut is pressed + */ + register: (shortcut, handler) => { + this.#handlers.set(shortcut, handler); + }, + /** + * Removes whatever handler is bound to the given shortcut + * @param shortcut - shortcut string in Editor.js codex notation + */ + unregister: (shortcut) => { + this.#handlers.delete(shortcut); + }, + }; + /** * @param params - {@link EditorjsPluginParams} */ @@ -48,20 +126,24 @@ export class ShortcutsPlugin implements EditorjsPlugin { const { detail } = event as ToolLoadedCoreEvent; const { tool } = detail; - const shortcut = tool.options['shortcut']; + const { shortcut } = tool.pluginOptions(ShortcutsPlugin.name) ?? {}; - if (typeof shortcut === 'string') { - this.#shortcutToToolName.set(shortcut, tool.name); + if (shortcut !== undefined) { + this.publicApi.register(shortcut, () => this.#processInlineTool(tool.name)); } /** * @todo support for "shortcuts" map for block tools / render overrides * @example * core.use(ListTool, { - * shortcuts: { - * 'CMD+U': { style: 'ul'}, - * 'CMD+O': { style: 'ol'}, - * } + * plugins: { + * shortcuts: { + * shortcuts: { + * 'CMD+U': { style: 'ul'}, + * 'CMD+O': { style: 'ol'}, + * }, + * }, + * }, * }) */ }); @@ -74,11 +156,11 @@ export class ShortcutsPlugin implements EditorjsPlugin { return; } - for (const [shortcut, toolName] of this.#shortcutToToolName) { + for (const [shortcut, handler] of this.#handlers) { if (matchKeyboardShortcut(nativeEvent, shortcut) === true) { nativeEvent.preventDefault(); - this.#processInlineTool(toolName); + handler(nativeEvent); return; } @@ -90,7 +172,7 @@ export class ShortcutsPlugin implements EditorjsPlugin { * Destroys the plugin */ public destroy(): void { - this.#shortcutToToolName.clear(); + this.#handlers.clear(); } /** diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index 683fa223..14e5b997 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -26,6 +26,11 @@ export * from './BlockToolAdapter/index.js'; export class DOMAdapters implements EditorJSAdapterPlugin { public static type = PluginType.Adapter as const; + /** + * Plugin name used to identify the adapter across the editor + */ + public static readonly name = 'dom-adapters'; + #iocContainer: Container = new Container({ autobind: true, defaultScope: 'Singleton', diff --git a/packages/plugins/clipboard-plugin/src/index.ts b/packages/plugins/clipboard-plugin/src/index.ts index ac344c54..54ca8f11 100644 --- a/packages/plugins/clipboard-plugin/src/index.ts +++ b/packages/plugins/clipboard-plugin/src/index.ts @@ -40,6 +40,11 @@ const EDITOR_JS_CLIPBOARD_MIME_TYPE = 'application/x-editor-js'; export class ClipboardPlugin implements EditorjsPlugin { public static readonly type = PluginType.Plugin; + /** + * Plugin name used to identify the plugin across the editor + */ + public static readonly name = 'clipboard'; + readonly #api: EditorAPI; readonly #eventBus: EventBus; #copyEventListener: ((e: CopyUIEvent) => void) | undefined; diff --git a/packages/sdk/src/api/EditorAPI.ts b/packages/sdk/src/api/EditorAPI.ts index 3b09ad39..b1c1a521 100644 --- a/packages/sdk/src/api/EditorAPI.ts +++ b/packages/sdk/src/api/EditorAPI.ts @@ -2,6 +2,7 @@ import type { BlocksAPI } from './BlocksAPI.js'; import type { SelectionAPI } from './SelectionAPI.js'; import type { DocumentAPI } from './DocumentAPI.js'; import type { TextAPI } from './TextAPI.js'; +import type { PluginsAPI } from '../index.js'; /** * Editor API interface @@ -27,4 +28,12 @@ export interface EditorAPI { * Text API to work with the text content of the document */ text: TextAPI; + + /** + * Public APIs exposed by the registered plugins, keyed by plugin `name`. + * + * Every entry is optional: the type map is global to a compilation while the registry is per + * editor instance, so importing a plugin's types does not mean it was registered here. + */ + plugins: PluginsAPI; } diff --git a/packages/sdk/src/entities/BaseTool.ts b/packages/sdk/src/entities/BaseTool.ts index c54d4d42..d958a1b9 100644 --- a/packages/sdk/src/entities/BaseTool.ts +++ b/packages/sdk/src/entities/BaseTool.ts @@ -3,6 +3,7 @@ import type { BlockToolOptions } from './BlockTool.js'; import type { InlineToolOptions } from './InlineTool.js'; import type { BlockTuneOptions } from './BlockTune.js'; import type { ToolType } from './EntityType.js'; +import type { ToolPluginOptions } from '../index.js'; /** * Canonical keys shared by every tool options interface. @@ -11,7 +12,12 @@ export enum BaseToolOptionKey { /** * Plugin-specific configuration object passed to the tool instance. */ - Config = 'config' + Config = 'config', + + /** + * Configuration the tool addresses to editor plugins, keyed by plugin `name`. + */ + Plugins = 'plugins' } /** @@ -25,6 +31,14 @@ export interface BaseToolOptions { * in the second argument of `core.use(Tool, options)`. */ [BaseToolOptionKey.Config]?: Config; + + /** + * Configuration this tool addresses to editor plugins, keyed by plugin `name`. + * Each plugin reads only its own slice via `BaseToolFacade.pluginOptions(name)`. + * @example + * static options = { plugins: { shortcuts: { shortcut: 'CMD+B' } } }; + */ + [BaseToolOptionKey.Plugins]?: ToolPluginOptions; } // Re-export so consumers can import all option types from this file diff --git a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts index 47a5aa28..163881f2 100644 --- a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts +++ b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts @@ -2,11 +2,13 @@ import type { EditorjsPlugin, EditorjsPluginConstructor } from './EditorjsPlugin import type { BlockId } from '@editorjs/model-types'; import type { PluginType } from './EntityType'; import type { BlockToolAdapter } from './BlockToolAdapter'; +import type { PluginId } from '../index.js'; /** * Base interface for adapter plugins + * @template Id - adapter's identifier, taken from its static `name` */ -export interface EditorJSAdapterPlugin extends EditorjsPlugin { +export interface EditorJSAdapterPlugin extends EditorjsPlugin { /** * Factory for the BlockToolAdapter. Called when a new block should be rendered * @param blockId - unique identifier of the added block @@ -25,8 +27,11 @@ export interface EditorJSAdapterPlugin extends EditorjsPlugin { /** * Constructor type for adapter plugins + * @template Id - adapter's identifier, taken from its static `name` */ -export interface EditorjsAdapterPluginConstructor extends EditorjsPluginConstructor { +export interface EditorjsAdapterPluginConstructor< + Id extends PluginId = PluginId +> extends EditorjsPluginConstructor> { /** * Marks the plugin as a singleton adapter, replaceable via core.use() */ diff --git a/packages/sdk/src/entities/EditorjsPlugin.spec.ts b/packages/sdk/src/entities/EditorjsPlugin.spec.ts new file mode 100644 index 00000000..67572e21 --- /dev/null +++ b/packages/sdk/src/entities/EditorjsPlugin.spec.ts @@ -0,0 +1,137 @@ +/* eslint-disable jsdoc/require-jsdoc,@typescript-eslint/no-magic-numbers */ + +import { describe, expect, it } from '@jest/globals'; +import type { EditorjsPlugin, EditorjsPluginConstructor, EditorjsPluginParams } from './EditorjsPlugin.js'; +import type { PluginId } from '../index.js'; +import { PluginType } from './EntityType.js'; + +/** + * Public API the fake plugin exposes + */ +interface ContractProbeApi { + /** + * Arbitrary method used to check the API type survives the map lookup + */ + ping(): string; +} + +declare module '../index.js' { + interface EditorjsPluginApiMap { + /** + * Fake plugin's public API + */ + contractProbe: ContractProbeApi; + } +} + +/** + * Plugin that declares its name explicitly, as every plugin is expected to + */ +class ProbePlugin implements EditorjsPlugin<'contractProbe'> { + public static readonly type = PluginType.Plugin; + + public static readonly name = 'contractProbe'; + + public readonly publicApi: ContractProbeApi = { + ping: () => 'pong', + }; + + /** + * @param _params - plugin dependencies, unused by the probe + */ + constructor(_params: EditorjsPluginParams) {} +} + +/** + * Plugin that omits the `name` declaration and so inherits `Function.name` + */ +class UnnamedPlugin implements EditorjsPlugin { + public static readonly type = PluginType.Plugin; + + /** + * @param _params - plugin dependencies, unused by the probe + */ + constructor(_params: EditorjsPluginParams) {} +} + +describe('EditorjsPlugin contract', () => { + it('should infer the id literal from a declared static name', () => { + const ctor: EditorjsPluginConstructor<'contractProbe', ProbePlugin> = ProbePlugin; + + expect(ctor.name).toBe('contractProbe'); + }); + + it('should type publicApi from the augmented map for a declared id', () => { + const plugin = new ProbePlugin({} as EditorjsPluginParams); + const api: ContractProbeApi = plugin.publicApi; + + expect(api.ping()).toBe('pong'); + }); + + it('should reject a publicApi that does not match the augmented map', () => { + class MismatchedPlugin implements EditorjsPlugin<'contractProbe'> { + public static readonly type = PluginType.Plugin; + + public static readonly name = 'contractProbe'; + + // @ts-expect-error -- `ping` must return a string + public readonly publicApi: ContractProbeApi = { ping: () => 42 }; + } + + expect(MismatchedPlugin.name).toBe('contractProbe'); + }); + + it('should fall back to the class name when the declaration is omitted', () => { + expect(UnnamedPlugin.name).toBe('UnnamedPlugin'); + }); + + it('should reject a publicApi on a plugin that does not narrow its id', () => { + class UndeclaredNamePlugin implements EditorjsPlugin { + public static readonly type = PluginType.Plugin; + + /** + * Without a narrowed id, `publicApi` resolves to `never` + */ + // @ts-expect-error -- publicApi is `never` until the plugin declares which id it is + public readonly publicApi: ContractProbeApi = { ping: () => 'pong' }; + } + + expect(UndeclaredNamePlugin.name).toBe('UndeclaredNamePlugin'); + }); + + it('should reject registering a plugin whose name is inherited from Function.name', () => { + /** + * Models the `use()` overload: the id is inferred from the constructor's static `name` + * @param _plugin - plugin constructor being registered + */ + function use(_plugin: EditorjsPluginConstructor): void {} + + use(ProbePlugin); + + class InheritedNamePlugin { + public static readonly type = PluginType.Plugin; + + public readonly publicApi: ContractProbeApi = { ping: () => 'pong' }; + } + + // @ts-expect-error -- `name` widens to `string`, so `publicApi` is expected to be `never` + use(InheritedNamePlugin); + + expect(InheritedNamePlugin.name).toBe('InheritedNamePlugin'); + }); + + it('should reject a static name that drifts from the augmented map key', () => { + class DriftedPlugin implements EditorjsPlugin<'contractProbe'> { + public static readonly type = PluginType.Plugin; + + public static readonly name = 'contractProb'; + + public readonly publicApi: ContractProbeApi = { ping: () => 'pong' }; + } + + // @ts-expect-error -- 'contractProb' is not the key the public API was augmented under + const ctor: EditorjsPluginConstructor<'contractProbe', DriftedPlugin> = DriftedPlugin; + + expect(ctor.name).toBe('contractProb'); + }); +}); diff --git a/packages/sdk/src/entities/EditorjsPlugin.ts b/packages/sdk/src/entities/EditorjsPlugin.ts index f1bec3d8..77293c29 100644 --- a/packages/sdk/src/entities/EditorjsPlugin.ts +++ b/packages/sdk/src/entities/EditorjsPlugin.ts @@ -2,6 +2,7 @@ import type { EventBus } from './EventBus/EventBus.js'; import type { CoreConfigValidated } from './Config.js'; import type { EditorAPI } from '../api'; import type { EntityType } from './EntityType.js'; +import type { EditorjsPluginApiMap, PluginId } from '../index.js'; /** * Parameters for EditorjsPlugin constructor @@ -23,10 +24,36 @@ export interface EditorjsPluginParams { eventBus: EventBus; } +/** + * Public API a plugin exposes for the given plugin id. + * + * Resolves to `never` for an id absent from {@link EditorjsPluginApiMap} — which is what a plugin + * that forgot to declare its static `name` falls into, since the inherited `Function.name` widens + * the id to `string`. Declaring a `publicApi` then fails to compile, pointing at the omission. + * + * The check is deliberately non-distributive (`[Id] extends [...]`): the default {@link PluginId} + * is a union including `string & {}`, and a distributive conditional would resolve that union to + * the known APIs instead of `never`, letting an undeclared plugin slip through. + */ +type PublicApiFor = [Id] extends [keyof EditorjsPluginApiMap] + ? EditorjsPluginApiMap[Id] + : never; + /** * Base interface for UI plugins */ -export interface EditorjsPlugin { +export interface EditorjsPlugin< + /** + * Plugin's identifier, matching its constructor's static `name` + */ + Id extends PluginId = PluginId +> { + /** + * API the plugin exposes to the integrator and to other plugins. + * Registered under the plugin's `name` and reachable as `api.plugins[name]`. + */ + publicApi?: PublicApiFor; + /** * Destroy plugin instance */ @@ -37,10 +64,14 @@ export interface EditorjsPlugin { * Constructor type for EditorjsPlugin */ export interface EditorjsPluginConstructor< + /** + * Plugin's identifier — inferred from the static `name` literal + */ + Id extends PluginId = PluginId, /** * Plugin's instance interface. Has to be a generic param as constructor can not be overloaded */ - Instance extends EditorjsPlugin = EditorjsPlugin + Instance extends EditorjsPlugin = EditorjsPlugin > { /** * Create new EditorjsPlugin instance @@ -51,4 +82,14 @@ export interface EditorjsPluginConstructor< * Plugin's entity type: UI plugin, Tool, etc. */ type: EntityType; + + /** + * Plugin name used to identify the plugin across the editor. Keys both the runtime registry + * behind `api.plugins` and the {@link EditorjsPluginApiMap} / `ToolPluginOptionsMap` type maps. + * + * Declare it as `public static readonly name = 'my-plugin'` so TypeScript infers the literal. + * Every class inherits `name: string` from `Function`, so omitting the declaration does not fail + * on its own — it widens the id, which makes any `publicApi` declaration a compile error. + */ + name: Id; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 494577e9..0b3ffdde 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,3 +1,60 @@ +/** + * Maps a plugin's `name` to the public API that plugin exposes through `api.plugins`. + * + * The interface is intentionally empty: plugin packages fill in their own row via module + * augmentation, so neither the SDK nor the Core ever needs to know a concrete plugin. + * + * Declared in this module (and not in `entities/`) on purpose — TypeScript merges an + * augmentation only into the module that declares the interface, so augmenting + * `@editorjs/sdk` would not reach a declaration that lives behind a re-export. + * @example + * declare module '@editorjs/sdk' { + * interface EditorjsPluginApiMap { + * shortcuts: ShortcutsPluginApi; + * } + * } + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled in by plugin packages via module augmentation +export interface EditorjsPluginApiMap {} + +/** + * Maps a plugin's `name` to the options shape tools may address to that plugin + * under `options.plugins`. + * + * Augmented by plugin packages the same way as {@link EditorjsPluginApiMap}. + * @example + * declare module '@editorjs/sdk' { + * interface ToolPluginOptionsMap { + * shortcuts: ShortcutsToolOptions; + * } + * } + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled in by plugin packages via module augmentation +export interface ToolPluginOptionsMap {} + +/** + * Identifier of a plugin — the value of its static `name`. + * + * Known ids (those present in either map) keep their literal type so they can be looked up in + * the maps; the `string & {}` arm keeps ids legal for plugins that augment neither map, while + * still preserving literal inference and autocomplete for the known ones. + */ +export type PluginId = keyof EditorjsPluginApiMap | keyof ToolPluginOptionsMap | (string & {}); + +/** + * Registry of plugin public APIs, exposed as `api.plugins`. + * + * `Partial` is what makes unknown keys a compile error, known keys typed, and every entry + * possibly `undefined` — the type map is global to a compilation while the registry is + * per editor instance, so a plugin whose types are imported may still never be registered. + */ +export type PluginsAPI = Partial; + +/** + * Plugin-directed configuration declared by a tool under `options.plugins`. + */ +export type ToolPluginOptions = Partial; + export * from './entities/index.js'; export * from './tools/index.js'; export type * from './api/index.js'; diff --git a/packages/sdk/src/pluginTypeMaps.spec.ts b/packages/sdk/src/pluginTypeMaps.spec.ts new file mode 100644 index 00000000..fa740c96 --- /dev/null +++ b/packages/sdk/src/pluginTypeMaps.spec.ts @@ -0,0 +1,111 @@ +/* eslint-disable jsdoc/require-jsdoc,@typescript-eslint/no-magic-numbers */ + +import { describe, expect, it } from '@jest/globals'; +import type { PluginsAPI, ToolPluginOptions } from './index.js'; + +/** + * Public API a fake plugin exposes — stands in for a real plugin package's API type + */ +interface ProbePluginApi { + /** + * Arbitrary method used to check the inferred signature survives the map lookup + * @param value - value to echo back + */ + echo(value: string): string; +} + +/** + * Options a tool may address to the fake plugin + */ +interface ProbeToolOptions { + /** + * Arbitrary option used to check the declaration is type-checked + */ + level: number; +} + +/** + * Augments the maps the same way a real plugin package does, but through a relative + * specifier since this file lives inside the declaring package itself + */ +declare module './index.js' { + interface EditorjsPluginApiMap { + /** + * Fake plugin's public API + */ + probe: ProbePluginApi; + } + + interface ToolPluginOptionsMap { + /** + * Fake plugin's tool-directed options + */ + probe: ProbeToolOptions; + } +} + +describe('Plugin type maps', () => { + describe('EditorjsPluginApiMap', () => { + it('should type an augmented key as the plugin API', () => { + const plugins: PluginsAPI = { + probe: { echo: value => value }, + }; + + const api: ProbePluginApi | undefined = plugins.probe; + + expect(api?.echo('called')).toBe('called'); + }); + + it('should make every entry optional so an unregistered plugin is representable', () => { + const plugins: PluginsAPI = {}; + + expect(plugins.probe).toBeUndefined(); + }); + + it('should reject reading a key no plugin has augmented', () => { + const plugins: PluginsAPI = {}; + + // @ts-expect-error -- `unknownPlugin` is absent from EditorjsPluginApiMap + expect(plugins.unknownPlugin).toBeUndefined(); + }); + + it('should reject an API value that does not match the augmented type', () => { + const plugins: PluginsAPI = { + // @ts-expect-error -- `echo` must return a string + probe: { echo: () => 42 }, + }; + + expect(plugins.probe).toBeDefined(); + }); + }); + + describe('ToolPluginOptionsMap', () => { + it('should type an augmented key as the tool-directed options', () => { + const options: ToolPluginOptions = { + probe: { level: 1 }, + }; + + const probeOptions: ProbeToolOptions | undefined = options.probe; + + expect(probeOptions?.level).toBe(1); + }); + + it('should reject options declared for a plugin no package has augmented', () => { + const options: ToolPluginOptions = { + // @ts-expect-error -- `unknownPlugin` is absent from ToolPluginOptionsMap + unknownPlugin: { level: 1 }, + }; + + expect(options).toBeDefined(); + }); + + it('should reject an option value of the wrong type', () => { + const options: ToolPluginOptions = { + // @ts-expect-error -- `level` is a number + probe: { level: 'high' }, + }; + + expect(options.probe).toBeDefined(); + }); + }); +}); diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts index 9e0d967d..15f078c8 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts @@ -48,7 +48,90 @@ function createBlockFacade( }); } +declare module '../../index.js' { + interface ToolPluginOptionsMap { + /** + * Options addressed to a fake plugin + */ + facadeProbe: { + /** + * Arbitrary option + */ + shortcut?: string; + /** + * Second option, used to check a slice is replaced rather than deep-merged + */ + scope?: string; + }; + + /** + * Options addressed to a second fake plugin, used for the disjoint-ids case + */ + facadeOther: { + /** + * Arbitrary option + */ + enabled: boolean; + }; + } +} + describe('BaseToolFacade (via BlockToolFacade)', () => { + describe('pluginOptions', () => { + it('should return the slice a tool declares for the requested plugin', () => { + const facade = createBlockFacade( + { plugins: { facadeProbe: { shortcut: 'CMD+B' } } }, + {} as ToolOptions + ); + + expect(facade.pluginOptions('facadeProbe')).toEqual({ shortcut: 'CMD+B' }); + }); + + it('should return undefined when the tool declares nothing for the plugin', () => { + const facade = createBlockFacade( + { plugins: { facadeOther: { enabled: true } } }, + {} as ToolOptions + ); + + expect(facade.pluginOptions('facadeProbe')).toBeUndefined(); + }); + + it('should return undefined when the tool declares no plugins key at all', () => { + const facade = createBlockFacade({}, {} as ToolOptions); + + expect(facade.pluginOptions('facadeProbe')).toBeUndefined(); + }); + + it('should let use() options win over static options for the same plugin', () => { + const facade = createBlockFacade( + { plugins: { facadeProbe: { shortcut: 'CMD+B' } } }, + { plugins: { facadeProbe: { shortcut: 'CMD+SHIFT+B' } } } as ToolOptions + ); + + expect(facade.pluginOptions('facadeProbe')).toEqual({ shortcut: 'CMD+SHIFT+B' }); + }); + + it('should replace the whole slice rather than deep-merging it', () => { + const facade = createBlockFacade( + { plugins: { facadeProbe: { shortcut: 'CMD+B', + scope: 'inline' } } }, + { plugins: { facadeProbe: { shortcut: 'CMD+SHIFT+B' } } } as ToolOptions + ); + + expect(facade.pluginOptions('facadeProbe')).toEqual({ shortcut: 'CMD+SHIFT+B' }); + }); + + it('should preserve plugin ids present in only one of the two sources', () => { + const facade = createBlockFacade( + { plugins: { facadeProbe: { shortcut: 'CMD+B' } } }, + { plugins: { facadeOther: { enabled: true } } } as ToolOptions + ); + + expect(facade.pluginOptions('facadeProbe')).toEqual({ shortcut: 'CMD+B' }); + expect(facade.pluginOptions('facadeOther')).toEqual({ enabled: true }); + }); + }); + describe('options getter', () => { it('merges static options with use() options, later keys win', () => { class BlockToolWithStaticOptions { @@ -80,6 +163,18 @@ describe('BaseToolFacade (via BlockToolFacade)', () => { }); }); + it('should merge the plugins key per plugin id rather than replacing it wholesale', () => { + const facade = createBlockFacade( + { plugins: { facadeProbe: { shortcut: 'CMD+B' } } }, + { plugins: { facadeOther: { enabled: true } } } as ToolOptions + ); + + expect(facade.options.plugins).toEqual({ + facadeProbe: { shortcut: 'CMD+B' }, + facadeOther: { enabled: true }, + }); + }); + it('uses only use() options when the tool class has no static options', () => { const facade = createBlockFacade(undefined, { foo: 'bar', diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.ts b/packages/sdk/src/tools/facades/BaseToolFacade.ts index 9a63c8b9..24b58cd7 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.ts @@ -11,6 +11,7 @@ import type { ToolTypeToOptions, ToolStaticOptions, BlockToolOptions, InlineToolOptions, BlockTuneOptions } from '../../entities/index.js'; import type { EditorAPI } from '../../api'; +import type { ToolPluginOptions, ToolPluginOptionsMap } from '../../index.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- need to allow any type here so extended interfaces pass export type ToolConstructable = BlockToolConstructor | InlineToolConstructor | BlockTuneConstructor; @@ -145,6 +146,39 @@ export abstract class BaseToolFacade(name: Id): ToolPluginOptionsMap[Id] | undefined { + return this.#mergedPluginOptions?.[name]; + } + + /** + * All plugin-directed slices merged per plugin id, or `undefined` when neither source has any. + */ + get #mergedPluginOptions(): ToolPluginOptions | undefined { + const fromTool = this.constructable.options?.[BaseToolOptionKey.Plugins]; + const fromUse = this.useToolOptions[BaseToolOptionKey.Plugins]; + + if (fromTool === undefined && fromUse === undefined) { + return undefined; + } + + return { + ...fromTool, + ...fromUse, }; } diff --git a/packages/tools/bold/src/index.ts b/packages/tools/bold/src/index.ts index ce4f86ec..1f0286c7 100644 --- a/packages/tools/bold/src/index.ts +++ b/packages/tools/bold/src/index.ts @@ -33,7 +33,9 @@ export class BoldInlineTool implements InlineTool { /** * Shortcuts plugin options */ - shortcut: 'CMD+B', + plugins: { + shortcuts: { shortcut: 'CMD+B' }, + }, } as const; /** diff --git a/packages/tools/italic/src/index.ts b/packages/tools/italic/src/index.ts index c1b567a3..b7045f7b 100644 --- a/packages/tools/italic/src/index.ts +++ b/packages/tools/italic/src/index.ts @@ -33,7 +33,9 @@ export class ItalicInlineTool implements InlineTool { /** * Shortcuts plugin options */ - shortcut: 'CMD+I', + plugins: { + shortcuts: { shortcut: 'CMD+I' }, + }, } as const; /** diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index 66f3037d..110f78de 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -6,6 +6,7 @@ import type { EventBus, import { CoreEventType, CopyUIEvent, + KeydownUIEvent, UiComponentType, BeforeInputUIEvent } from '@editorjs/sdk'; @@ -116,6 +117,17 @@ export class BlocksUI implements EditorjsPlugin { }); blocksHolder.addEventListener('keydown', (e) => { + /** + * Delegate the keydown so plugins (e.g. Shortcuts) can act on it first. + * The bus dispatches synchronously, so a plugin that handled the key has already + * called preventDefault by the time this returns — treat that as "consumed". + */ + this.#eventBus.dispatchEvent(new KeydownUIEvent({ nativeEvent: e })); + + if (e.defaultPrevented) { + return; + } + if (e.code !== 'KeyZ') { return; }