diff --git a/openspec/changes/separate-tool-options-and-config/.openspec.yaml b/openspec/changes/separate-tool-options-and-config/.openspec.yaml new file mode 100644 index 00000000..e8209ffa --- /dev/null +++ b/openspec/changes/separate-tool-options-and-config/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-28 diff --git a/openspec/changes/separate-tool-options-and-config/design.md b/openspec/changes/separate-tool-options-and-config/design.md new file mode 100644 index 00000000..24d25077 --- /dev/null +++ b/openspec/changes/separate-tool-options-and-config/design.md @@ -0,0 +1,109 @@ +## Context + +Today a tool's static surface is one bag (`ToolConstructor.options`) that mixes two things with different audiences and different lifetimes: + +- **Core/plugin-facing wiring** — `toolbox`, `shortcut`, `inlineToolbar`, `tunes`, `conversionConfig`, `canBeSplit`, `isReadOnlySupported`. Read by `BaseToolFacade`/`BlockToolFacade` getters and consumed by plugins (`ToolboxUI`, `ShortcutsPlugin`, inline toolbar, tunes) **before any block instance exists**. +- **Tool-author-facing user data** — `ToolConfig`, nested under `options.config`, merged with any `use()`-time `config` override in `BaseToolFacade.config`, and handed to the tool's `constructor`/`prepare()`. + +`ToolConfig` itself is not an SDK contract — it's re-exported from the legacy `@editorjs/editorjs` package as `type ToolConfig = T`, an untyped passthrough. A tool author gets whatever structure they declare in their own `Config` generic parameter, with nothing checking that it's consistent with anything else. + +The concrete failure this produces: a tool declares a config field intended to shape one of its static options — a set of variants that should become toolbox entries, a mode that should pick a shortcut — and the field is inert. `options` is evaluated once, statically, at class-definition time, while `config` is only resolved per tool-registration (in `BaseToolFacade.config`) and per block instance (in the constructor), so no option value can be a function of it. The field remains in the tool's `Config` type and in its documentation while the option keeps its hardcoded value. + +Constraints this design has to respect: +- A page can run multiple `Core` instances that both `use()` the same tool class (e.g. two editors sharing one imported class). Anything a tool computes from its own resolved config must not be written onto the shared class/static object, or the second instance's config would clobber the first's. +- Every consumer of a tool's static options reads it through a **synchronous** getter (`BlockToolFacade.toolbox`, `isReadOnlySupported`, `conversionConfig`, `InlineToolFacade`'s option reads). Whatever produces those values must therefore be resolvable synchronously by the time the facade exists. +- Per this change's proposal, a breaking change to `ToolConfig`'s type/import and to the `static options` type is acceptable; a live "change config after mount" API is explicitly out of scope. +- The project's existing TDD convention applies to the facade changes below (see `openspec/config.yaml` rules). + +## Goals / Non-Goals + +**Goals:** +- Make `ToolConfig` an SDK-owned, real contract (not a re-exported `any`-defaulted passthrough). +- Keep `ToolOptions` (`BlockToolOptions`/`InlineToolOptions`/`BlockTuneOptions`) as the static, core/plugin-facing contract, formally distinct from `ToolConfig`. +- Let a tool derive its static options from its `ToolConfig` — without mutating the tool's shared static class property, and without adding a resolution step to the tool-registration lifecycle. +- Keep the change's blast radius inside the SDK: no new plumbing in `packages/core`. + +**Non-Goals:** +- No live/reactive config updates after the editor has mounted (confirmed out of scope — config is resolved once, at facade construction). +- No change to `prepare()`. Async, side-effectful tool initialization keeps working exactly as it does today. +- No change to how `toolbox`, `shortcut`, `inlineToolbar`, or `tunes` are merged with `use()`-time overrides — the merge algorithm and its tiers stay as they are. +- No dev-time "declared but unused config key" diagnostic in this change (see Decision 8). +- Not migrating tools that live in their own repositories. This change ships the SDK mechanism; adopting the factory form is each tool's own follow-up. + +## Decisions + +**1. `ToolConfig` becomes an SDK-owned type, not a re-export of `@editorjs/editorjs`'s passthrough.** +The legacy `ToolConfig = T` is how the untyped-escape-hatch problem enters v3 in the first place — any tool author who doesn't explicitly parameterize their `Config` generic silently gets `any`. SDK defines its own `ToolConfig` base (still generic per tool, but anchored in `@editorjs/sdk` so it's the type the rest of this design's checks can hook into). +*Alternative considered*: leave the re-export and only tighten the `Config extends ToolConfig` bound on each interface (`BlockToolOptions`, etc.). Rejected — the default still resolves to `any` for any tool that skips the generic, which is exactly today's failure mode. + +**2. `options` and `config` stay two separate top-level concepts; `options.config` remains the only bridge.** +This matches how they're actually consumed: `options` (via facade getters) is read by core/plugins before any block exists; `config` is read by the tool instance itself. Collapsing them into one `ToolSettings` bag would erase that timing distinction, which is the actual source of the bug. +*Alternative considered*: a single unified settings object passed everywhere. Rejected — every plugin consumer would need to filter out user-data noise, and it doesn't resolve the "when is this available" question. + +**3. Config-derived options are expressed by letting `options` itself be a function of `config` — not by routing them through `prepare()`'s return value.** +`BaseToolConstructor.options` widens to: +```ts +type ToolOptionsFactory = (config: Config) => Options; + +options?: Options | ToolOptionsFactory; +``` +The two hooks answer different questions and have different natures. `prepare()` is *asynchronous, side-effectful initialization* — its v2 role is loading external resources before the tool can be used, and it is called for its effects, not its value. Deriving `toolbox` entries from `config.levels` is a *pure, synchronous* mapping over data the framework already holds. Merging the two conflates those natures, and a chain of machinery follows from the merge: because `prepare()` may be async, its result cannot be read by the synchronous option getters, so it must be resolved eagerly and cached behind a dedicated type and a facade slot, and that caching must in turn be sequenced by `ToolsManager` ahead of `ToolLoadedCoreEvent`. Keeping the derivation on `options` itself makes all of it unnecessary. +*Alternative considered*: widen `prepare()` to return a `PreparedToolOptions` object that the facade stores and its option getters consult. Rejected on the above; concretely it costs a new exported type, three new members on `BaseToolFacade` (a private field, a getter, a setter), capture-and-inject logic in `ToolsManager.prepareTools()`, an ordering guarantee relative to `ToolLoadedCoreEvent` that anyone touching that method must preserve, and an allow-list of derivable fields — starting at `toolbox` alone — that has to be widened by hand for every further field. The factory form derives the whole option object with none of it. +*Alternative considered*: a second static hook alongside `options`, e.g. `resolveOptions(config)`. Rejected — it keeps two declaration sites for one concept, which is the thing the review objected to; the union type on a single `options` member has one declaration site and one resolution site. + +**4. The factory receives the `use()`-time config, and applies its own defaults inside its body.** +The argument is `useToolOptions.config ?? {}` — exactly the object an integrator passed to `core.use(Tool, { config })`. A tool applies its defaults where it derives from them: +```ts +static options = (config: MyToolConfig) => { + const variants = config.variants ?? DEFAULT_VARIANTS; + + return { + config: { variants, defaultVariant: config.defaultVariant ?? variants[0] }, + toolbox: variants.map(variant => ({ title: titleFor(variant), icon: ICONS[variant], data: { variant } })), + }; +}; +``` +`BaseToolFacade.config` then merges as it always has — `{ ...resolvedOptions.config, ...useToolOptions.config }` — so the tool *instance* still receives a config carrying the defaults, and the object form of `options` behaves byte-for-byte as it does today. +*Alternative considered*: pass the fully merged config (tool-side defaults ∪ `use()`-time override) into the factory, mirroring what `prepare()` receives today. Rejected as genuinely circular: the tool-side defaults live in `options.config`, which for a factory-form tool only exists in that factory's own return value, so building its input would require its output. +*Trade-off accepted*: for factory-form tools the framework no longer pre-merges `options.config` defaults *before* the derivation runs — the tool writes `?? default` itself. In exchange the defaults live in exactly one place in the tool's source, next to the code that consumes them, instead of being split between a static `options.config` block and the logic that reads it. + +**5. The factory is synchronous.** +Every consumer of static options is a synchronous getter, so allowing a `Promise` return would reintroduce precisely the eager-resolve-and-cache problem Decision 3 removes: `ToolsManager` would again have to await the value and inject it before `ToolLoadedCoreEvent`, and the facade would again need a "not resolved yet" state. A tool that needs asynchronous work before it can operate still has `prepare()`; that work simply cannot feed the tool's static declaration. +*Alternative considered*: `options?: Options | ((config) => Options | Promise)`. Rejected — no evidenced case (nothing in-repo, and the derivations this targets are pure maps over config values), and it costs the entire simplification. + +**6. The factory is resolved once, in the facade constructor, into a private per-instance field.** +`BaseToolFacade` computes `isFunction(constructable.options) ? constructable.options(useConfig) : (constructable.options ?? {})` at construction time and keeps the result in a private field. Every option-reading getter — `options` and `config` on `BaseToolFacade`, `toolbox` and `isReadOnlySupported` on `BlockToolFacade` — reads that field rather than `constructable.options`. Those four getters are the only places in the repo that read a tool class's static options directly, so the conversion is fully enumerable. +This makes the multi-`Core` constraint hold *by construction* rather than by mitigation: the factory is a pure function on the class, each facade calls it with its own `useToolOptions.config`, and nothing is ever written back to the shared `constructable`. Two `Core` instances using one imported tool class with different configs cannot interfere. +*Alternative considered*: resolve lazily and memoize on first read. Rejected as equivalent in effect but worse in failure mode — a throwing factory would surface at an arbitrary getter read rather than at tool registration. +*Alternative considered*: call the factory on every getter read. Rejected — a tool author would reasonably assume a single call, and repeated calls would make `toolbox` entry identity unstable across reads. + +**7. `toolbox` resolution keeps its existing two tiers and its existing merge algorithm.** +`BlockToolFacade.toolbox` still resolves tool-side value, then the explicit `use()`-time override on top, with the same array/object positional-merge rules and the same `toolbox: false` hiding behavior. Only the *source* of the tool-side value changes, from `constructable.options.toolbox` to the resolved static options. The factory result replaces what the static object would have been; it is not an extra layer. +*Alternative considered*: a third tier, in which a separately-derived toolbox value takes precedence over the static one before the `use()` override is applied on top (`derived ?? static`, then `use()`). Rejected with Decision 3 — a third tier is only needed when derivation arrives through a channel *alongside* `options`; when the factory *is* `options`, there is nothing left to layer. + +**8. Detecting a declared-but-unused config key is out of scope for this change.** +A tempting companion feature is a dev-time warning when a tool declares a `ToolConfig` key that nothing ever reads — an inert field would then announce itself instead of failing silently. The obvious implementation, wrapping the resolved config in a `Proxy` that records key access during tool registration, does not survive contact with the lifecycle: the only registration-time hook a tool exposes is `prepare()`, which most tools — including all four in-repo ones — do not define at all, so the check would never run for them; and a key read in the tool's *constructor* happens per block instance, after any registration-time observation window has closed, so it would be reported as unused. Getting this right needs a reachability model — a decision about what "used" means — that this change does not otherwise require, and the bug is fixed by making the field *derivable*, not by warning about it. +*Alternative considered*: keep it and fix it in place (run for all tools, treat any config read as use). Rejected as scope creep onto a contract change — it needs its own design discussion about the reachability model. + +**9. Tools that keep the object form of `options` are unaffected at runtime.** +`bold`, `italic`, `inline-link`, and `paragraph` all declare `static readonly options = { ... }` and none needs config-derived options; their migration is limited to importing the new SDK-owned `ToolConfig` type. The union type is additive — the object branch is the existing behavior. + +## Risks / Trade-offs + +- **[Risk]** `options` becoming a union (object or factory) means any code reading it must narrow first, and a tool's static options can no longer be inspected without invoking the factory. → **Mitigation**: resolution is centralized in one place (the `BaseToolFacade` constructor) and the 5 existing read sites are converted to read the resolved field; the facade is the sanctioned read path for everything outside the tool itself, and there is no in-repo consumer that reads `Tool.options` directly off a class. +- **[Risk]** For factory-form tools the framework no longer merges `options.config` defaults into the value handed to the derivation, so a tool author who forgets a `?? default` gets `undefined` where the object form would have given them a default. → **Mitigation**: the factory's `config` parameter is typed as the tool's own `Config`, so the optionality is visible at the call site; and the defaults the factory returns under `config` still flow to the tool instance through the unchanged `BaseToolFacade.config` merge. +- **[Risk]** Sync-only factories mean a genuinely async-derived option (e.g. a toolbox built from a fetched preset list) is not expressible. → **Mitigation**: no such case exists in-repo or in the motivating PR; if one appears, revisiting Decision 5 is a contained change (the facade would need a resolved/unresolved state and `ToolsManager` an await point) rather than a redesign. + +## Migration Plan + +- Ship the SDK contract changes (`ToolConfig`, the widened `options` type) and the facade resolution together — they are tightly coupled; a partial rollout would leave the factory form declared in the types with no resolver behind it. +- Update the four in-repo tools' `Config` type imports (mechanical, type-only, no runtime behavior change). +- No feature flag or staged rollout: this is a pre-1.0 internal SDK surface, and per this change's confirmed scope a breaking change is acceptable with no external in-repo consumers beyond the tools already covered. +- Rollback: revert the SDK/facade commits together; the tools' type-only import changes revert cleanly since they carry no runtime behavior. + +## Open Questions + +- Should the factory also receive `toolName`, as `prepare()` does (`(config, toolName) => Options` or an object argument)? Leaning no — no evidenced need, and a single positional `config` argument keeps the signature readable; the object-argument form is the escape hatch if a second input ever appears. +- `BlockToolFacade.isReadOnlySupported` reads the tool-side options only, so a `use()`-time override cannot enable/disable it. This change relocates that read to the resolved static options but preserves the behavior. Whether it *should* honor a `use()` override is a separate question, deliberately not answered here. +- Should "unused config key" detection (Decision 8) become its own proposal, and if so, what counts as a key being used — read by the options factory, by `prepare()`, or by the tool's constructor? The constructor case is the hard one, since it is per block instance rather than per registration. +- How would a third-party plugin (i.e. not one of the framework's own Toolbox/Shortcuts/InlineToolbar/Tunes consumers) read its own config-derived data off a tool? `BlockToolOptions`/`InlineToolOptions`/`BlockTuneOptions` already carry a `[key: string]: unknown` escape hatch for custom static fields, and the factory form derives those alongside everything else — so this is arguably now answered, but no third-party case has been tested against it. diff --git a/openspec/changes/separate-tool-options-and-config/proposal.md b/openspec/changes/separate-tool-options-and-config/proposal.md new file mode 100644 index 00000000..5c4278ef --- /dev/null +++ b/openspec/changes/separate-tool-options-and-config/proposal.md @@ -0,0 +1,32 @@ +## Why + +Tools expose two static surfaces with no formal separation: `static options` (core/plugin-facing wiring — `toolbox`, `shortcut`, `inlineToolbar`, `tunes`, `conversionConfig`, `canBeSplit`) and `ToolConfig` (plugin-specific user data, typed only via a generic `Config extends ToolConfig = any` re-exported from the legacy `@editorjs/editorjs` package, where `ToolConfig = T` is an untyped passthrough). Because `options` is a plain static value evaluated once at class-definition time, a tool has no way to express "these option values are derived from my resolved config" — there is no contract connecting a `ToolConfig` field to the `options` it is meant to drive. + +The gap is structural rather than stylistic. `options` is read off the class before any registration exists, while `config` is resolved per tool registration (in `BaseToolFacade.config`) and per block instance (in the tool's constructor). A config field meant to drive an option value — toolbox entries following a set of variants, a shortcut depending on a mode, conversion behavior gated by a flag — has no path to it. The field stays declared in the tool's `Config` type and documented for integrators, while the option it was meant to control keeps whatever the class hardcoded, and nothing reports the mismatch. + +## What Changes + +- Define `ToolConfig` as an SDK-owned, per-tool-type-parameterized contract, replacing the untyped passthrough currently imported from `@editorjs/editorjs`. **BREAKING**: a tool's `Config` generic must conform to the new contract's shape and import path. +- Allow a tool's static `options` to be, in addition to a plain object, a **synchronous factory** `(config: ToolConfig) => ToolOptions`. The factory receives the `config` supplied at `use(Tool, { config })` time and returns the tool's complete static option set — including any config defaults it applies, under `options.config`, exactly as the object form declares them. **BREAKING** for the `static options` type of every tool. +- Resolve that factory exactly once, when the tool's facade is constructed, into a private per-facade field. Every option-reading getter (`options`, `config`, `toolbox`, `isReadOnlySupported`) reads the resolved value instead of `constructable.options`, so nothing is ever written back onto the shared tool class. +- Explicitly **do not** change `prepare()`. It stays the `void`-returning, optionally-async, side-effectful initialization hook it is today; deriving static options from config is a separate, pure, synchronous concern and is not routed through it. +- Migrate the four in-repo tools (`paragraph`, `bold`, `italic`, `inline-link`) to the new `ToolConfig`/`ToolOptions` contracts. All four keep the plain-object form of `options`; none needs config-derived options. +- Out of scope: changing a tool's config after the editor has already mounted (no live/reactive "hot-swap" API). Config is resolved once, during tool registration, before the editor renders its UI. +- Out of scope: dev-time detection of a declared-but-unused `ToolConfig` key. It is a distinct diagnostics concern with its own open question (what counts as "used" — a key read in a factory, in `prepare()`, or in the tool's constructor?) and does not belong in the contract change. + +## Capabilities + +### New Capabilities +(none — this reshapes the existing tool-contract behavior rather than introducing a new capability area) + +### Modified Capabilities +- `sdk`: the "Tool and tune contracts" requirement changes — `ToolConfig` becomes a dedicated SDK contract (no longer a passthrough re-export), and `BaseToolConstructor.options` widens from a plain object to "a plain object **or** a synchronous factory of the resolved config", with the facade owning a single resolution of that factory per registration. + +## Impact + +- `packages/sdk/src/entities/{BaseTool.ts, BlockTool.ts, InlineTool.ts, BlockTune.ts}`: SDK-owned `ToolConfig`; `options` widened to accept a config factory. `prepare()`'s signature is unchanged. +- `packages/sdk/src/tools/facades/BaseToolFacade.ts`: a private per-instance field holding the resolved static options, populated in the constructor; the `options` and `config` getters read it instead of `constructable.options`. +- `packages/sdk/src/tools/facades/BlockToolFacade.ts`: the `toolbox` and `isReadOnlySupported` getters read the resolved static options. The `toolbox` merge algorithm and its two tiers (tool-side value, then `use()`-time override) are unchanged. +- `packages/core/src/tools/ToolsManager.ts`: unchanged. Resolution happens entirely inside the SDK facade, so the tool-registration lifecycle gains no new step and no new ordering guarantee. +- `packages/tools/{paragraph,bold,italic,inline-link}`: migrate to the new `ToolConfig` import/contract (type-only change; all keep object-form `options`). +- `openspec/specs/sdk/spec.md`: delta spec updates to the "Tool and tune contracts" requirement. diff --git a/openspec/changes/separate-tool-options-and-config/specs/sdk/spec.md b/openspec/changes/separate-tool-options-and-config/specs/sdk/spec.md new file mode 100644 index 00000000..218fb37f --- /dev/null +++ b/openspec/changes/separate-tool-options-and-config/specs/sdk/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: Tool and tune contracts +The system SHALL define the static/instance contracts that block tools, inline tools, and block tunes must satisfy: `BaseTool`/`BaseToolConstructor` (common `name`, `options`, `prepare()`, `reset()`), `BlockTool`/`BlockToolConstructor` (adds `toolbox`, `shortcut`, `inlineToolbar`, `tunes`, `conversionConfig`, `canBeSplit`), `InlineTool`/`InlineToolConstructor` (adds `isActive`, `getFormattingOptions`, `createWrapper`, `getToolbarConfig`), and `BlockTune`/`BlockTuneConstructor`. `ToolConfig` SHALL be an SDK-owned contract (not a re-exported passthrough from `@editorjs/editorjs`), kept formally distinct from `ToolOptions`: `options` describes core/plugin-facing wiring available before any block instance exists, while `ToolConfig` describes tool-author-facing user data resolved per tool registration. A tool's static `options` SHALL be either a plain options object or a synchronous factory taking the tool's `ToolConfig` and returning that options object; the facade SHALL resolve the factory exactly once, at construction, and serve every option-reading getter from that resolved value without writing it back onto the tool class. + +#### Scenario: Options and config merging in a tool facade +- **GIVEN** a tool class has static `options` (and optionally `options.config` typed as `ToolConfig`) +- **WHEN** the tool is registered via `use(Tool, options)` with overriding options +- **THEN** the facade's `options` getter merges the tool's resolved static options with `use()`-time options, with `use()`-time keys taking precedence, and the `config` getter merges similarly, injecting `defaultPlaceholder` only when `isDefault` is true and no `placeholder` key is already present + +#### Scenario: Resolving static options from a config factory +- **GIVEN** a tool class declares `static options` as a function of its `ToolConfig` rather than as a plain object +- **WHEN** its facade is constructed for a registration made via `use(Tool, { config })` +- **THEN** the function is invoked exactly once with the `config` supplied at `use()` time, its returned options object becomes the tool's resolved static options for that facade, and the tool class's own `options` property is left untouched + +#### Scenario: Deriving a toolbox entry from a config value +- **GIVEN** a block tool's `options` factory computes `toolbox` entries from a field of the config it receives +- **WHEN** the facade's `toolbox` getter is read +- **THEN** the entries reflect the config supplied at `use()` time, and any explicit `use()`-time `toolbox` override is merged on top using the existing array/object positional-merge algorithm, with a `use()`-time `toolbox: false` still hiding the tool from the toolbox + +#### Scenario: Isolating factory-derived options per facade instance +- **GIVEN** two `Core` instances each register the same tool class through `use()` with a different `config` +- **WHEN** each instance's facade resolves the tool's `options` factory +- **THEN** each facade holds its own resolved options and neither instance's derived values are observable from the other + +#### Scenario: Text content conversion without config +- **GIVEN** a block tool has no `conversionConfig` +- **WHEN** `exportTextContent`/`importTextContent` is called on its facade +- **THEN** it throws a descriptive error stating the tool does not have export/import configuration + +#### Scenario: Text content conversion with a keypath +- **GIVEN** a block tool's `conversionConfig` specifies a dot-notation string key (including nested array paths, e.g. `items.0.text`) +- **WHEN** `exportTextContent`/`importTextContent` is called +- **THEN** the value at that keypath is read/written, producing or consuming a `TextNodeSerialized` value tagged with the hidden `Text` block-child-type marker + +Implemented in `src/entities/BaseTool.ts`, `BlockTool.ts`, `InlineTool.ts`, `BlockTune.ts`, `src/tools/facades/{BaseToolFacade,BlockToolFacade,InlineToolFacade,BlockTuneFacade}.ts`, validated by `src/tools/facades/BaseToolFacade.spec.ts`. diff --git a/openspec/changes/separate-tool-options-and-config/tasks.md b/openspec/changes/separate-tool-options-and-config/tasks.md new file mode 100644 index 00000000..c0d91979 --- /dev/null +++ b/openspec/changes/separate-tool-options-and-config/tasks.md @@ -0,0 +1,41 @@ +## 1. SDK-owned `ToolConfig` + +- [x] 1.1 Add a failing test in `packages/sdk/src/entities/BaseTool.spec.ts` — `it('should expose ToolConfig from the SDK itself rather than re-export it from @editorjs/editorjs')` — asserting `BaseToolOptions` references the SDK-owned type +- [x] 1.2 Define `ToolConfig` in `packages/sdk/src/entities/BaseTool.ts` as an SDK-owned generic type, replacing the `@editorjs/editorjs` re-export, and update `BaseToolOptions` +- [x] 1.3 Update `BlockTool.ts`, `InlineTool.ts`, and `BlockTune.ts` to import `ToolConfig` from the new location — each re-declares its own `Config extends ToolConfig = ToolConfig` bound rather than inheriting `BaseToolOptions`' one, so this is covered by its own test (`should keep the config option checked on every tool subtype`) + +## 2. `options` as a config factory + +- [x] 2.1 Add a failing test in `packages/sdk/src/entities/BaseTool.spec.ts` — `it('should accept a synchronous factory of the tool config as static options')` — asserting a `BaseToolConstructor` type-checks with `options` declared as `(config: Config) => Options` +- [x] 2.2 Add a failing test asserting the object form still type-checks unchanged — `it('should accept a plain options object as static options')` +- [x] 2.3 Define `ToolOptionsFactory` in `packages/sdk/src/entities/BaseTool.ts` and widen `BaseToolConstructor.options` to `Options | ToolOptionsFactory` +- [x] 2.4 Propagate the widened `options` type through `BlockToolConstructor`, `InlineToolConstructor`, and `BlockTuneConstructor` — no production change was needed: all three extend `BaseToolConstructor` without re-declaring `options`, so they inherit the union. Pinned by `it('should carry the factory form through to every tool subtype constructor')` +- [x] 2.5 Confirm `BaseToolConstructor.prepare()` keeps its current `void | Promise` return type — design.md Decision 3 deliberately leaves this hook untouched + +## 3. Resolving the factory in `BaseToolFacade` + +- [x] 3.1 Add a failing test in `packages/sdk/src/tools/facades/BaseToolFacade.spec.ts` — `it('should call the options factory once with the config passed at use() time')` +- [x] 3.2 Add a failing test — `it('should not call the options factory again when option getters are read repeatedly')` +- [x] 3.3 Add a failing test — `it('should leave the tool class options untouched after resolving the factory')` +- [x] 3.4 Add a failing test — `it('should resolve options independently for two facades wrapping the same tool class with different configs')` (the multi-`Core` case, design.md Decision 6) +- [x] 3.5 Add a failing test — `it('should merge factory-returned config defaults with the use()-time config')` — covering that a tool instance receives the defaults the factory applied +- [x] 3.6 Implement the private resolved-options field in the `BaseToolFacade` constructor: invoke the factory with `useToolOptions.config ?? {}` when `options` is a function, otherwise use the object as-is. Declared `protected readonly` rather than `#private` because `BlockToolFacade` reads it (group 5); per-instance semantics are unchanged +- [x] 3.7 Point the `options` and `config` getters at the resolved field instead of `constructable.options` + +## 4. Reading resolved options in `BlockToolFacade` + +- [x] 4.1 Add failing tests for `BlockToolFacade.toolbox` covering both tiers against a factory-derived value — `it('should derive toolbox entries from the config passed at use() time')`, `it('should merge a use()-time toolbox override onto a factory-derived value')`, and `it('should hide the tool when a use()-time toolbox is false despite a factory-derived value')` +- [x] 4.2 Add a failing test — `it('should read isReadOnlySupported from the resolved static options')` +- [x] 4.3 Point `BlockToolFacade.toolbox` and `isReadOnlySupported` at the resolved static options, leaving the array/object positional-merge algorithm and its two tiers unchanged +- [x] 4.4 Confirm the existing object-form toolbox tests still pass unmodified — **none existed**: the toolbox merge algorithm had no test coverage at all. Added five characterization tests in the new `BlockToolFacade.spec.ts` (empty, single-entry wrapping, `false` hiding, array-onto-array positional merge, object-onto-object merge) and confirmed they pass before and after 4.3 + +## 5. Migrating in-repo tools + +- [x] 5.1 Update `packages/tools/{paragraph,bold,italic,inline-link}` to import `ToolConfig`/their `Config` type from the new SDK location, keeping the plain-object form of `static options` — only `paragraph` referenced `ToolConfig`; `bold`, `italic`, and `inline-link` declare no config type +- [x] 5.2 Confirm `yarn workspace typecheck` and `yarn lint` pass for each of the four tools with no runtime behavior change + +## 6. Verification and documentation alignment + +- [x] 6.1 Confirm `yarn workspace @editorjs/sdk test`, `yarn workspace @editorjs/core test`, and `yarn lint` pass — 34 SDK tests, 161 core tests, lint clean +- [x] 6.2 Run `openspec validate --changes separate-tool-options-and-config --strict` and fix any delta-spec formatting issues +- [x] 6.3 Re-check `docs/plugins.md` and `docs/architecture.md` for statements about `static options` always being a plain object, and update any that no longer hold — neither file makes such a statement, no change needed diff --git a/packages/sdk/src/entities/BaseTool.spec.ts b/packages/sdk/src/entities/BaseTool.spec.ts new file mode 100644 index 00000000..641e9233 --- /dev/null +++ b/packages/sdk/src/entities/BaseTool.spec.ts @@ -0,0 +1,87 @@ +/* eslint-disable jsdoc/require-jsdoc */ + +import { describe, expect, it } from '@jest/globals'; +import type { + BaseToolConstructor, + BaseToolOptions, + BlockToolOptions, + BlockTuneOptions, + InlineToolOptions, + ToolConfig +} from './BaseTool.js'; +import type { + BlockToolConstructor, + BlockToolData, + BlockTuneConstructor, + InlineToolConstructor +} from './index.js'; + +interface TestToolConfig { + level?: number; +} + +/** + * Resolves to `true` only when `T` is exactly `any`. + * + * `1 & any` collapses back to `any`, and `0 extends any` holds — a relation no + * other type satisfies. This is what distinguishes an SDK-owned `ToolConfig` + * from a re-export of the legacy `ToolConfig = T`. + */ +type IsAny = 0 extends (1 & T) ? true : false; + +describe('ToolConfig', () => { + it('should expose ToolConfig from the SDK itself rather than re-export it from @editorjs/editorjs', () => { + const toolConfigIsAny: IsAny = false; + const optionsConfigIsAny: IsAny> = false; + + expect(toolConfigIsAny).toBe(false); + expect(optionsConfigIsAny).toBe(false); + }); + + it('should keep the config option checked on every tool subtype', () => { + const blockConfigIsAny: IsAny> = false; + const inlineConfigIsAny: IsAny> = false; + const tuneConfigIsAny: IsAny> = false; + + expect(blockConfigIsAny).toBe(false); + expect(inlineConfigIsAny).toBe(false); + expect(tuneConfigIsAny).toBe(false); + }); +}); + +describe('BaseToolConstructor.options', () => { + it('should accept a synchronous factory of the tool config as static options', () => { + const tool: BaseToolConstructor = { + name: 'factory-tool', + options: config => ({ + config: { level: config.level ?? 1 }, + }), + }; + + expect(typeof tool.options).toBe('function'); + }); + + it('should accept a plain options object as static options', () => { + const tool: BaseToolConstructor = { + name: 'object-tool', + options: { + config: { level: 1 }, + }, + }; + + expect(typeof tool.options).toBe('object'); + }); + + it('should carry the factory form through to every tool subtype constructor', () => { + const blockOptions: BlockToolConstructor['options'] = config => ({ + toolbox: [{ title: `Level ${config.level ?? 1}`, + icon: '' }], + }); + const inlineOptions: InlineToolConstructor['options'] = () => ({ config: {} }); + const tuneOptions: BlockTuneConstructor['options'] = () => ({ config: {} }); + + expect(typeof blockOptions).toBe('function'); + expect(typeof inlineOptions).toBe('function'); + expect(typeof tuneOptions).toBe('function'); + }); +}); diff --git a/packages/sdk/src/entities/BaseTool.ts b/packages/sdk/src/entities/BaseTool.ts index c54d4d42..6e6a0bfd 100644 --- a/packages/sdk/src/entities/BaseTool.ts +++ b/packages/sdk/src/entities/BaseTool.ts @@ -1,9 +1,22 @@ -import type { ToolConfig } from '@editorjs/editorjs'; import type { BlockToolOptions } from './BlockTool.js'; import type { InlineToolOptions } from './InlineTool.js'; import type { BlockTuneOptions } from './BlockTune.js'; import type { ToolType } from './EntityType.js'; +/** + * Plugin-specific, tool-author-facing configuration object. + * + * Kept formally distinct from a tool's static options: `options` is core/plugin-facing + * wiring read before any block instance exists, while `ToolConfig` is user data resolved + * per tool registration and handed to the tool instance. + * + * Replaces the legacy `ToolConfig` re-exported from `@editorjs/editorjs`, whose default + * type argument was `any` — meaning any tool that omitted the generic silently opted out + * of type checking on its own configuration. + * @template T - Shape of the tool's own configuration object. + */ +export type ToolConfig = T; + /** * Canonical keys shared by every tool options interface. */ @@ -27,6 +40,26 @@ export interface BaseToolOptions { [BaseToolOptionKey.Config]?: Config; } +/** + * Derives a tool's static options from its resolved configuration. + * + * Declared in place of a plain options object when some option value depends on + * config — e.g. a `toolbox` whose entries follow a `levels` config field. The + * factory is called once per tool registration, with the `config` supplied to + * `core.use(Tool, { config })`, so it must apply its own defaults for keys the + * integrator omitted. + * + * It must be synchronous: every consumer of static options reads it through a + * synchronous facade getter. Asynchronous tool initialization belongs in + * {@link BaseToolConstructor.prepare} instead. + * @template Config - Shape of the plugin-specific config object. + * @template Options - The concrete options interface for this tool type. + */ +export type ToolOptionsFactory< + Config extends ToolConfig = ToolConfig, + Options extends BaseToolOptions = BaseToolOptions +> = (config: Config) => Options; + // Re-export so consumers can import all option types from this file export type { BlockToolOptions, InlineToolOptions, BlockTuneOptions }; @@ -77,8 +110,12 @@ export interface BaseToolConstructor< * All static configuration for the tool. * Values here are defaults; they can be overridden via the second argument * of `core.use(Tool, options)`. + * + * May also be a {@link ToolOptionsFactory} — a synchronous function of the tool's + * config — when option values are derived from configuration. The facade resolves + * it once, per registration, and never writes the result back onto the tool class. */ - options?: Options; + options?: Options | ToolOptionsFactory; /** * Tool's prepare method. Can be async. diff --git a/packages/sdk/src/entities/BlockTool.ts b/packages/sdk/src/entities/BlockTool.ts index ab18196a..cbe9a16b 100644 --- a/packages/sdk/src/entities/BlockTool.ts +++ b/packages/sdk/src/entities/BlockTool.ts @@ -1,13 +1,12 @@ import type { BlockTool as BlockToolVersion2, - ToolConfig, ToolboxConfigEntry } from '@editorjs/editorjs'; import type { BlockToolConstructorOptions as BlockToolConstructorOptionsVersion2 } from '@editorjs/editorjs'; import type { ValueSerialized } from '@editorjs/model-types'; import type { BlockToolAdapter } from './BlockToolAdapter.js'; import type { ToolType } from './EntityType.js'; -import type { BaseToolConstructor, BaseToolOptions } from './BaseTool'; +import type { BaseToolConstructor, BaseToolOptions, ToolConfig } from './BaseTool'; import type { EditorAPI } from '../api'; /** diff --git a/packages/sdk/src/entities/BlockTune.ts b/packages/sdk/src/entities/BlockTune.ts index 547d0b11..c699aa4f 100644 --- a/packages/sdk/src/entities/BlockTune.ts +++ b/packages/sdk/src/entities/BlockTune.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { - ToolConfig, BlockTune as BlockTuneV2 } from '@editorjs/editorjs'; import type { ToolType } from './EntityType.js'; -import type { BaseToolConstructor, BaseToolOptions } from './BaseTool'; +import type { BaseToolConstructor, BaseToolOptions, ToolConfig } from './BaseTool'; /** * Options available on **Block Tunes** (`static options` or `use()` overrides). diff --git a/packages/sdk/src/entities/InlineTool.ts b/packages/sdk/src/entities/InlineTool.ts index 6d86de0e..3b24f1e0 100644 --- a/packages/sdk/src/entities/InlineTool.ts +++ b/packages/sdk/src/entities/InlineTool.ts @@ -1,9 +1,9 @@ import type { TextRange, InlineFragment } from '@editorjs/model-types'; import type { FormattingAction, IntersectType } from '@editorjs/model-types'; import type { InlineTool as InlineToolVersion2 } from '@editorjs/editorjs'; -import type { InlineToolConstructorOptions as InlineToolConstructorOptionsVersion2, ToolConfig } from '@editorjs/editorjs'; +import type { InlineToolConstructorOptions as InlineToolConstructorOptionsVersion2 } from '@editorjs/editorjs'; import type { ToolType } from './EntityType.js'; -import type { BaseToolConstructor, BaseToolOptions } from './BaseTool'; +import type { BaseToolConstructor, BaseToolOptions, ToolConfig } from './BaseTool'; import type { EditorAPI } from '../api'; import type { MenuConfig } from './MenuConfig.js'; diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts index 9e0d967d..73a6b892 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts @@ -12,6 +12,31 @@ import type { EditorAPI } from '../../api'; const emptyApi = {} as EditorAPI; +/** + * Wraps an already-declared tool class in a facade, so a test can keep a reference + * to the class itself (e.g. to assert its static `options` were not mutated). + * @param constructable - tool class to wrap + * @param useToolOptions - second argument of `use(Tool, options)` + * @param facadeOpts - `isDefault` / `defaultPlaceholder` for the facade constructor + */ +function facadeFor( + constructable: unknown, + useToolOptions: ToolOptions, + facadeOpts: { + isDefault?: boolean; + defaultPlaceholder?: string | false; + } = {} +): BlockToolFacade { + return new BlockToolFacade({ + api: emptyApi, + constructable: constructable as BlockToolConstructor, + defaultPlaceholder: facadeOpts.defaultPlaceholder, + isDefault: facadeOpts.isDefault ?? false, + name: 'test-tool', + useToolOptions, + }); +} + /** * Block tool facade with only fields needed to exercise BaseToolFacade getters. * @param staticOptions - optional object set on the mock class as static `options` @@ -19,7 +44,7 @@ const emptyApi = {} as EditorAPI; * @param facadeOpts - `isDefault` / `defaultPlaceholder` for the facade constructor */ function createBlockFacade( - staticOptions: Record | undefined, + staticOptions: unknown, useToolOptions: ToolOptions, facadeOpts: { isDefault?: boolean; @@ -38,14 +63,12 @@ function createBlockFacade( }); } - return new BlockToolFacade({ - api: emptyApi, - constructable: MockBlockTool as unknown as BlockToolConstructor, - defaultPlaceholder: facadeOpts.defaultPlaceholder, - isDefault: facadeOpts.isDefault ?? false, - name: 'test-tool', - useToolOptions, - }); + return facadeFor(MockBlockTool, useToolOptions, facadeOpts); +} + +interface LevelsConfig { + levels?: number[]; + defaultLevel?: number; } describe('BaseToolFacade (via BlockToolFacade)', () => { @@ -164,6 +187,103 @@ describe('BaseToolFacade (via BlockToolFacade)', () => { }); }); + describe('options factory', () => { + it('should call the options factory once with the config passed at use() time', () => { + const seen: LevelsConfig[] = []; + const factory = (config: LevelsConfig): Record => { + seen.push(config); + + return { config }; + }; + + createBlockFacade(factory, { + [UserToolOptions.Config]: { levels: [1, 3] }, + } as ToolOptions); + + expect(seen).toEqual([{ levels: [1, 3] }]); + }); + + it('should not call the options factory again when option getters are read repeatedly', () => { + let callCount = 0; + const factory = (): Record => { + callCount += 1; + + return { config: { levels: [1] } }; + }; + + const facade = createBlockFacade(factory, {} as ToolOptions); + + void facade.options; + void facade.config; + void facade.options; + + expect(callCount).toBe(1); + }); + + it('should leave the tool class options untouched after resolving the factory', () => { + const factory = (config: LevelsConfig): Record => ({ + config: { levels: config.levels ?? [1] }, + }); + + class ToolWithFactoryOptions { + public static type = ToolType.Block; + public static options = factory; + } + + facadeFor(ToolWithFactoryOptions, { + [UserToolOptions.Config]: { levels: [2] }, + } as ToolOptions); + + expect(ToolWithFactoryOptions.options).toBe(factory); + }); + + it('should resolve options independently for two facades wrapping the same tool class with different configs', () => { + class SharedTool { + public static type = ToolType.Block; + public static options = (config: LevelsConfig): Record => { + const levels = config.levels ?? [1]; + + return { config: { defaultLevel: levels[levels.length - 1] } }; + }; + } + + const first = facadeFor(SharedTool, { + [UserToolOptions.Config]: { levels: [1] }, + } as ToolOptions); + const second = facadeFor(SharedTool, { + [UserToolOptions.Config]: { levels: [2, 3] }, + } as ToolOptions); + + expect(first.config).toEqual({ + levels: [1], + defaultLevel: 1, + }); + expect(second.config).toEqual({ + levels: [2, 3], + defaultLevel: 3, + }); + }); + + it('should merge factory-returned config defaults with the use()-time config', () => { + const facade = createBlockFacade( + (config: LevelsConfig): Record => ({ + config: { + levels: config.levels ?? [1, 2, 3], + defaultLevel: config.defaultLevel ?? 2, + }, + }), + { + [UserToolOptions.Config]: { levels: [4] }, + } as ToolOptions + ); + + expect(facade.config).toEqual({ + levels: [4], + defaultLevel: 2, + }); + }); + }); + describe('exportTextContent', () => { it('throws when the tool has no conversionConfig.export', () => { const facade = createBlockFacade({}, {} as ToolOptions); diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.ts b/packages/sdk/src/tools/facades/BaseToolFacade.ts index 9a63c8b9..96a16180 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.ts @@ -8,7 +8,8 @@ import { ToolType, BaseToolOptionKey } from '../../entities/index.js'; import { type BlockTuneFacade } from './BlockTuneFacade.js'; import type { BlockTool, BlockToolConstructor, InlineTool, InlineToolConstructor, BlockTuneConstructor, - ToolTypeToOptions, ToolStaticOptions, BlockToolOptions, InlineToolOptions, BlockTuneOptions + ToolTypeToOptions, ToolStaticOptions, BlockToolOptions, InlineToolOptions, BlockTuneOptions, + ToolOptionsFactory } from '../../entities/index.js'; import type { EditorAPI } from '../../api'; @@ -104,6 +105,16 @@ export abstract class BaseToolFacade { - const staticOpts = this.constructable.options; - const fromTool = (staticOpts?.[BaseToolOptionKey.Config] ?? {}) as Record; + const fromTool = (this.resolvedStaticOptions[BaseToolOptionKey.Config] ?? {}) as Record; const fromUse = (this.useToolOptions[UserToolOptions.Config] ?? {}) as Record; const merged: Record = { ...fromTool, @@ -219,6 +228,28 @@ export abstract class BaseToolFacade { + describe('toolbox getter with a plain options object', () => { + it('should return undefined when the tool declares no toolbox', () => { + const facade = createFacade({}); + + expect(facade.toolbox).toBeUndefined(); + }); + + it('should wrap a single toolbox entry into an array', () => { + const facade = createFacade({ + [BlockToolOptionKey.Toolbox]: { + title: 'Text', + icon: '', + }, + }); + + expect(facade.toolbox).toEqual([{ + title: 'Text', + icon: '', + }]); + }); + + it('should hide the tool when the use()-time toolbox is false', () => { + const facade = createFacade( + { [BlockToolOptionKey.Toolbox]: { title: 'Text' } }, + { [UserToolOptions.Toolbox]: false } as ToolOptions + ); + + expect(facade.toolbox).toBeUndefined(); + }); + + it('should merge a use()-time toolbox array onto the tool array positionally', () => { + const facade = createFacade( + { + [BlockToolOptionKey.Toolbox]: [ + { + title: 'H1', + icon: 'tool-1', + }, + { + title: 'H2', + icon: 'tool-2', + }, + ], + }, + { + [UserToolOptions.Toolbox]: [{ title: 'Heading 1' }], + } as ToolOptions + ); + + expect(facade.toolbox).toEqual([{ + title: 'Heading 1', + icon: 'tool-1', + }]); + }); + + it('should merge a use()-time toolbox object onto a tool toolbox object', () => { + const facade = createFacade( + { + [BlockToolOptionKey.Toolbox]: { + title: 'Text', + icon: 'tool-icon', + }, + }, + { + [UserToolOptions.Toolbox]: { title: 'Paragraph' }, + } as ToolOptions + ); + + expect(facade.toolbox).toEqual([{ + title: 'Paragraph', + icon: 'tool-icon', + }]); + }); + }); + + describe('toolbox getter with an options factory', () => { + it('should derive toolbox entries from the config passed at use() time', () => { + const facade = createFacade( + (config: LevelsConfig): Record => ({ + [BlockToolOptionKey.Toolbox]: (config.levels ?? [1, 2, 3]).map(level => ({ + title: `Heading ${level}`, + data: { level }, + })), + }), + { [UserToolOptions.Config]: { levels: [2, 4] } } as ToolOptions + ); + + expect(facade.toolbox).toEqual([ + { + title: 'Heading 2', + data: { level: 2 }, + }, + { + title: 'Heading 4', + data: { level: 4 }, + }, + ]); + }); + + it('should merge a use()-time toolbox override onto a factory-derived value', () => { + const facade = createFacade( + (config: LevelsConfig): Record => ({ + [BlockToolOptionKey.Toolbox]: (config.levels ?? [1]).map(level => ({ + title: `Heading ${level}`, + icon: `icon-${level}`, + })), + }), + { + [UserToolOptions.Config]: { levels: [1, 2] }, + [UserToolOptions.Toolbox]: [{ title: 'Title' }], + } as ToolOptions + ); + + expect(facade.toolbox).toEqual([{ + title: 'Title', + icon: 'icon-1', + }]); + }); + + it('should hide the tool when a use()-time toolbox is false despite a factory-derived value', () => { + const facade = createFacade( + (config: LevelsConfig): Record => ({ + [BlockToolOptionKey.Toolbox]: (config.levels ?? [1]).map(level => ({ title: `Heading ${level}` })), + }), + { + [UserToolOptions.Config]: { levels: [1, 2] }, + [UserToolOptions.Toolbox]: false, + } as ToolOptions + ); + + expect(facade.toolbox).toBeUndefined(); + }); + }); + + describe('isReadOnlySupported getter', () => { + it('should read isReadOnlySupported from a plain options object', () => { + const facade = createFacade({ [BlockToolOptionKey.IsReadOnlySupported]: true }); + + expect(facade.isReadOnlySupported).toBe(true); + }); + + it('should read isReadOnlySupported from the resolved static options', () => { + const facade = createFacade( + (config: LevelsConfig): Record => ({ + [BlockToolOptionKey.IsReadOnlySupported]: (config.levels ?? []).length > 0, + }), + { [UserToolOptions.Config]: { levels: [1] } } as ToolOptions + ); + + expect(facade.isReadOnlySupported).toBe(true); + }); + }); +}); diff --git a/packages/sdk/src/tools/facades/BlockToolFacade.ts b/packages/sdk/src/tools/facades/BlockToolFacade.ts index 4176f8da..3d26cca0 100644 --- a/packages/sdk/src/tools/facades/BlockToolFacade.ts +++ b/packages/sdk/src/tools/facades/BlockToolFacade.ts @@ -51,6 +51,12 @@ export class BlockToolFacade extends BaseToolFacade { */ protected declare useToolOptions: BlockToolOptions; + /** + * Narrowed to BlockToolOptions — the tool's static options after the facade resolved + * them, whether they were declared as a plain object or as a factory of the config + */ + protected declare readonly resolvedStaticOptions: BlockToolOptions; + /** * Creates new Tool instance * @param options - Tool constructor options @@ -73,7 +79,7 @@ export class BlockToolFacade extends BaseToolFacade { * Returns true if read-only mode is supported by Tool */ public get isReadOnlySupported(): boolean { - return this.constructable.options?.[BlockToolOptionKey.IsReadOnlySupported] === true; + return this.resolvedStaticOptions[BlockToolOptionKey.IsReadOnlySupported] === true; } /** @@ -98,7 +104,7 @@ export class BlockToolFacade extends BaseToolFacade { * config. This is made to allow user to override default tool's toolbox representation (single/multiple entries) */ public get toolbox(): ToolboxConfigEntry[] | undefined { - const toolToolboxSettings = this.constructable.options?.[BlockToolOptionKey.Toolbox] as ToolboxConfig; + const toolToolboxSettings = this.resolvedStaticOptions[BlockToolOptionKey.Toolbox] as ToolboxConfig; const userToolboxSettings = this.useToolOptions[UserToolOptions.Toolbox]; if (isEmpty(toolToolboxSettings)) { diff --git a/packages/tools/paragraph/src/index.ts b/packages/tools/paragraph/src/index.ts index e9f56cb3..5fcf7732 100644 --- a/packages/tools/paragraph/src/index.ts +++ b/packages/tools/paragraph/src/index.ts @@ -1,11 +1,11 @@ -import type { ToolConfig } from '@editorjs/editorjs'; import type { BlockTool, BlockToolConstructor, BlockToolConstructorOptions, BlockToolData, KeyAddedEvent, - TextNodeSerialized + TextNodeSerialized, + ToolConfig } from '@editorjs/sdk'; import { KeyRemovedEvent } from '@editorjs/sdk'; import { ToolType } from '@editorjs/sdk';