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;
}