diff --git a/.github/actions/base-coverage/action.yml b/.github/actions/base-coverage/action.yml index 3dc3b6de..a0499958 100644 --- a/.github/actions/base-coverage/action.yml +++ b/.github/actions/base-coverage/action.yml @@ -22,15 +22,27 @@ runs: fetch-depth: 0 submodules: true - - name: Check package exists on base ref + # A base-ref comparison only means something when the base ref can actually produce + # a coverage report. Two cases cannot: the package may not exist there yet, or it may + # exist without a test suite, which is the case for the PR that first adds one. The + # second needs its own check -- package.json is present, so the file test passes, and + # `yarn workspace ... test:coverage` then fails with "couldn't find a script". + - name: Check base ref can report coverage id: check shell: bash run: | - if [ -f "${{ inputs.working-directory }}/package.json" ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else + if [ ! -f "${{ inputs.working-directory }}/package.json" ]; then echo "Package not present on base ref (new package), skipping base coverage" echo "exists=false" >> "$GITHUB_OUTPUT" + elif ! node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync(process.argv[1] + '/package.json', 'utf8')); + process.exit(pkg.scripts && pkg.scripts['test:coverage'] ? 0 : 1); + " "${{ inputs.working-directory }}"; then + echo "Package has no test:coverage script on base ref (suite added in this PR), skipping base coverage" + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "exists=true" >> "$GITHUB_OUTPUT" fi - name: Setup Node diff --git a/.github/agents/docs-updater.agent.md b/.github/agents/docs-updater.agent.md index 3cf34626..a54601c8 100644 --- a/.github/agents/docs-updater.agent.md +++ b/.github/agents/docs-updater.agent.md @@ -27,7 +27,7 @@ Follow this sequence for every run: | Changed area | Primary doc(s) | Diagram(s) | |---|---|---| | Package list, dependencies, overall structure | `docs/architecture.md`, root `README.md` | `diagrams/architecture-overview.mmd` | -| `EditorJSModel`, `EditorDocument`, `BlockNode`, `TextNode`, `ValueNode`, `BlockTune`, `Index`, `CaretManager` | `docs/model.md` | `diagrams/model-tree-structure.mmd` | +| `EditorJSModel`, `EditorDocument`, `BlockNode`, `TextNode`, `ValueNode`, `PluginDataNode`, `Index`, `CaretManager` | `docs/model.md` | `diagrams/model-tree-structure.mmd` | | Event classes, `EventType`, `EventBus` | `docs/events.md` | `diagrams/events-catalog.mmd` | | `Core`, `BlocksManager`, `BlockRenderer`, `SelectionManager`, `ToolsManager`, `EditorAPI`, plugin/tool lifecycle | `docs/plugins.md` | `diagrams/plugin-lifecycle-flow.mmd` | | `DOMBlockToolAdapter`, `CaretAdapter`, `FormattingAdapter`, `InputsRegistry`, `BeforeInputUIEvent` | `docs/input-handling.md` | `diagrams/block-adapter-input-flow.mmd`, `diagrams/caret-selection-flow.mmd`, `diagrams/inline-formatting-flow.mmd` | diff --git a/.github/workflows/default-block-settings-plugin.yml b/.github/workflows/default-block-settings-plugin.yml new file mode 100644 index 00000000..eb927d49 --- /dev/null +++ b/.github/workflows/default-block-settings-plugin.yml @@ -0,0 +1,20 @@ +name: Default block settings plugin check +on: + pull_request: + merge_group: + +permissions: + contents: read + pull-requests: write + issues: write + actions: read + +jobs: + package-check: + uses: ./.github/workflows/package-check.yml + with: + package-name: '@editorjs/default-block-settings-plugin' + working-directory: './packages/plugins/default-block-settings-plugin' + include-mutations: true + secrets: + stryker_dashboard_api_key: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml new file mode 100644 index 00000000..ef3c7680 --- /dev/null +++ b/.github/workflows/ui.yml @@ -0,0 +1,10 @@ +name: UI check +on: + pull_request: + +jobs: + package-check: + uses: ./.github/workflows/package-check.yml + with: + package-name: '@editorjs/ui' + working-directory: './packages/ui' diff --git a/docs/README.md b/docs/README.md index 8fb98390..d93f3855 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,7 +61,7 @@ The system stays decoupled because each step communicates through interfaces and - `EditorjsPlugin`: general UI/behavior plugin registered via `core.use()` with `PluginType.Plugin`. UI components like `BlocksUI` typically declare a `static type` from `UiComponentType` but are bound to `PluginType.Plugin` internally. - `UiComponentType`: reserved string keys for UI component slots (`shell`, `blocks`, `inline-toolbar`, `toolbox`, `toolbar`). These name components in the UI layer but are **not** used as arguments to `core.use()` — plugins are registered by `PluginType` or `ToolType` values. -- `BlockTool` / `InlineTool` / `BlockTune`: tool contracts registered via `core.use(ToolConstructor, options)` during setup. The `tools` config field provides tool settings that `ToolsManager` applies during `initialize()`. +- `BlockTool` / `InlineTool`: tool contracts registered via `core.use(ToolConstructor, options)` during setup. The `tools` config field provides tool settings that `ToolsManager` applies during `initialize()`. - `Index`: serializable location in the document tree, independent of DOM nodes. Subclasses (e.g. `BlockIndex`, `TextIndex`, `DataIndex`) cover distinct target types. Built with immutable factory methods like `Index.block()` or `Index.text()`; serialized to a compact JSON string for caret storage and OT operations. - `DataKey`: branded string identifying a data slot inside a `BlockNode` (e.g. `"text"`, `"caption"`). Created via `createDataKey()`. - `BatchedOperation`: groups rapid single-character inserts or deletes on the same data key into one logical edit for undo/redo. Lives in `@editorjs/collaboration-manager`. diff --git a/docs/diagrams/architecture-overview.mmd b/docs/diagrams/architecture-overview.mmd index a1b1f5b6..9bf0bd0d 100644 --- a/docs/diagrams/architecture-overview.mmd +++ b/docs/diagrams/architecture-overview.mmd @@ -26,7 +26,7 @@ classDiagram +block(blockIndex, documentId?)$ BlockIndex +text(segments)$ TextIndex +data(blockIndex, dataKey)$ DataIndex - +tune(blockIndex, tuneName, tuneKey)$ TuneIndex + +pluginData(blockIndex, pluginName, pluginKey)$ PluginDataIndex } } diff --git a/docs/diagrams/events-catalog.mmd b/docs/diagrams/events-catalog.mmd index d2313c4b..3c2fa817 100644 --- a/docs/diagrams/events-catalog.mmd +++ b/docs/diagrams/events-catalog.mmd @@ -80,7 +80,7 @@ classDiagram detail.data.value: T detail.data.previous: T } - class TuneModifiedEvent { + class PluginDataModifiedEvent { detail.data.value: T detail.data.previous: T } @@ -163,7 +163,7 @@ classDiagram DataNodeAddedEvent --|> BaseDocumentEvent DataNodeRemovedEvent --|> BaseDocumentEvent ValueModifiedEvent --|> BaseDocumentEvent - TuneModifiedEvent --|> BaseDocumentEvent + PluginDataModifiedEvent --|> BaseDocumentEvent PropertyModifiedEvent --|> BaseDocumentEvent BlockAddedCoreEvent --|> CoreEventBase diff --git a/docs/diagrams/model-tree-structure.mmd b/docs/diagrams/model-tree-structure.mmd index b7f54b2a..95a7bb4d 100644 --- a/docs/diagrams/model-tree-structure.mmd +++ b/docs/diagrams/model-tree-structure.mmd @@ -38,7 +38,7 @@ classDiagram class BlockNode { +name: BlockToolName +data: BlockNodeData - +tunes: Record~BlockTuneName, BlockTune~ + +plugins: Record~PluginDataName, PluginDataNode~ +parent: EditorDocument +createDataNode(key, data) +removeDataNode(key) @@ -47,9 +47,10 @@ classDiagram +getFragments(key, ...): InlineFragment[] } - class BlockTune { - +name: BlockTuneName + class PluginDataNode { + +name: PluginDataName +data: Record~string, unknown~ + +isEmpty: boolean +update(key, value) } @@ -123,7 +124,7 @@ classDiagram EditorJSModel --|> EventBus EditorDocument --|> EventBus BlockNode --|> EventBus - BlockTune --|> EventBus + PluginDataNode --|> EventBus ValueNode --|> EventBus ParentInlineNode --|> EventBus @@ -134,7 +135,7 @@ classDiagram EditorDocument "1" *-- "0..*" BlockNode : children BlockNode "1" *-- "0..*" TextNode : data[key] BlockNode "1" *-- "0..*" ValueNode : data[key] - BlockNode "1" *-- "0..*" BlockTune : tunes[name] + BlockNode "1" *-- "0..*" PluginDataNode : plugins[name] %% Inline tree TextNode --|> ParentInlineNode diff --git a/docs/diagrams/plugin-lifecycle-flow.mmd b/docs/diagrams/plugin-lifecycle-flow.mmd index 7c9d9250..09562ec3 100644 --- a/docs/diagrams/plugin-lifecycle-flow.mmd +++ b/docs/diagrams/plugin-lifecycle-flow.mmd @@ -31,7 +31,7 @@ sequenceDiagram Core->>Plugins: bind PluginType.Plugin = SomePlugin Dev->>Core: use(SomeTool, settings) - Core->>Plugins: bind ToolType.Block / Inline / Tune = [SomeTool, settings] + Core->>Plugins: bind ToolType.Block / Inline = [SomeTool, settings] %% ── await initialize() ─────────────────────────── Dev->>Core: await initialize() @@ -47,9 +47,9 @@ sequenceDiagram Core->>CollabMgr: new CollaborationManager(config, api, eventBus) CollabMgr->>EventBus: wire event listeners - Core->>Plugins: getAll(ToolType.Block / Inline / Tune) + Core->>Plugins: getAll(ToolType.Block / Inline) Plugins-->>Core: [[SomeTool, settings], ...] - Core->>ToolsManager: prepareTools(blockTools, inlineTools, blockTunes) + Core->>ToolsManager: prepareTools(blockTools, inlineTools) loop for each tool ToolsManager->>Tool: Tool.prepare({ toolName, config }) ToolsManager->>ToolsManager: wrap in BlockToolFacade or InlineToolFacade diff --git a/docs/events.md b/docs/events.md index 7e740804..40282acc 100644 --- a/docs/events.md +++ b/docs/events.md @@ -4,7 +4,7 @@ The editor exposes two public event transports. | Transport | Dispatched on | Event types | |---|---|---| -| `EditorJSModel` `EventType.Changed` | `EditorJSModel` instance | `BlockAddedEvent`, `BlockRemovedEvent`, `TextAddedEvent`, `TextRemovedEvent`, `TextFormattedEvent`, `TextUnformattedEvent`, `ValueModifiedEvent`, `TuneModifiedEvent`, `DataNodeAddedEvent`, `DataNodeRemovedEvent`, `PropertyModifiedEvent` | +| `EditorJSModel` `EventType.Changed` | `EditorJSModel` instance | `BlockAddedEvent`, `BlockRemovedEvent`, `TextAddedEvent`, `TextRemovedEvent`, `TextFormattedEvent`, `TextUnformattedEvent`, `ValueModifiedEvent`, `PluginDataModifiedEvent`, `DataNodeAddedEvent`, `DataNodeRemovedEvent`, `PropertyModifiedEvent` | | `EditorJSModel` `EventType.CaretManagerUpdated` | `EditorJSModel` instance | `CaretManagerCaretUpdatedEvent`, `CaretManagerCaretAddedEvent`, `CaretManagerCaretRemovedEvent` | | Core `EventBus` (per editor instance) | `EventBus` held in the IoC container | `BlockAddedCoreEvent`, `BlockRemovedCoreEvent`, `ToolLoadedCoreEvent`, `SelectionChangedCoreEvent`, `UndoCoreEvent`, `RedoCoreEvent`, `BeforeInputUIEvent` | @@ -27,7 +27,7 @@ All document mutation events extend `BaseDocumentEvent`: | `TextFormattedEvent` | `{ tool, data }` | An inline tool was applied to a text range | | `TextUnformattedEvent` | `{ tool, data }` | An inline tool was removed from a text range | | `ValueModifiedEvent` | `{ value, previous }` | A `ValueNode`'s value was changed | -| `TuneModifiedEvent` | `{ value, previous }` | A `BlockTune`'s data was updated | +| `PluginDataModifiedEvent` | `{ value, previous }` | One key of a plugin's per-block data was updated; `value` is `undefined` when the key was removed | | `DataNodeAddedEvent` | `BlockNodeDataSerializedValue` | A data node (text or value) was created on a block | | `DataNodeRemovedEvent` | `BlockNodeDataSerializedValue` | A data node was removed from a block | | `PropertyModifiedEvent` | `{ value, previous }` | A top-level document property was set | @@ -47,9 +47,24 @@ Dispatched on the IoC-managed `EventBus` (one instance per editor) with prefixed | `UndoCoreEvent` | `core:undo` | — | `DocumentAPI.undo()` or `BlocksUI` (Cmd/Ctrl+Z) | | `RedoCoreEvent` | `core:redo` | — | `DocumentAPI.redo()` or `BlocksUI` (Cmd/Ctrl+Shift+Z) | | `BeforeInputUIEvent` | `ui:before-input` | `{ data, inputType, targetRanges, isCrossInputSelection, isComposing }` | `BlocksUI` | +| `BlockSelectedUIEvent` | `ui:blocks:block-selected` | `{ block: HTMLElement, index }` | `BlocksUI`, on pointer enter and on the caret moving into another block | +| `BlockSettingsOpenUIEvent` | `ui:block-settings:open` | `{ index }` | anyone opening the menu programmatically | +| `BlockSettingsRenderedUIEvent` | `ui:block-settings:rendered` | `{ button: HTMLElement, blockSettings: HTMLElement }` | `BlockSettingsUI` | +| `BlockSettingsOpenedUIEvent` | `ui:block-settings:opened` | — | `BlockSettingsUI` | +| `BlockSettingsClosedUIEvent` | `ui:block-settings:closed` | — | `BlockSettingsUI` | `BlockAddedCoreEvent` carries the rendered `HTMLElement` in `detail.ui`, while the model-level `BlockAddedEvent` carries serialised data — they are complementary. +`BlockSettingsUI` owns both its menu and the button that opens it, and announces the two +together so a host can place them: `ToolbarUI` puts the button among its own controls and the +menu beside the toolbox popover. `ui:block-settings:open` is therefore not how the button works +— it is the way anything *else* opens the menu, naming the target block by index. + +`BlockSelectedUIEvent` is what "the selected block" means to the toolbar and to anything +mounted in it. It is dispatched for the pointer entering a block *and* for the caret moving +into one, so a control acting on the selected block still works for someone who never touches +a mouse. Repeats for the same block are not dispatched. + Use core/UI events for UI workflows and extension coordination. ## Adapter internals diff --git a/docs/index-serialization.md b/docs/index-serialization.md index d9fc1f15..8bd1535f 100644 --- a/docs/index-serialization.md +++ b/docs/index-serialization.md @@ -64,19 +64,19 @@ All fields that can be omitted (`id`) are absent from the JSON when `undefined` --- -## `TuneIndex` — `k: "tune"` +## `PluginDataIndex` — `k: "plugin"` ```json -{ "k": "tune", "b": 2, "tune": "", "key": "", "id": "" } +{ "k": "plugin", "b": 2, "plugin": "", "key": "", "id": "" } ``` -| Field | Type | Required | -|--------|----------|----------| -| `k` | `"tune"` | yes | -| `b` | number | yes — zero-based block position | -| `tune` | string | yes — block tune name | -| `key` | string | yes — key within the tune | -| `id` | string | no | +| Field | Type | Required | +|----------|------------|----------| +| `k` | `"plugin"` | yes | +| `b` | number | yes — zero-based block position | +| `plugin` | string | yes — name the data is stored under, by convention the plugin's `name` | +| `key` | string | yes — key within that plugin's data | +| `id` | string | no | --- @@ -167,13 +167,13 @@ Index.block(2, 'doc-abc').serialize() // → '{"k":"block","b":2,"id":"doc-abc"}' ``` -### Tune property change +### Plugin data change -A `fontSize` tune on block 1, key `"size"` changed. +The `fontSize` plugin's `"size"` key changed on block 1. ```ts -Index.tune(1, 'fontSize', 'size').serialize() -// → '{"k":"tune","b":1,"tune":"fontSize","key":"size"}' +Index.pluginData(1, 'fontSize', 'size').serialize() +// → '{"k":"plugin","b":1,"plugin":"fontSize","key":"size"}' ``` ### Round-trip through `Index.parse` diff --git a/docs/model.md b/docs/model.md index b01c057d..72d79c28 100644 --- a/docs/model.md +++ b/docs/model.md @@ -10,7 +10,7 @@ Each `BlockNode` contains keyed data nodes: - `TextNode`: rich text with inline tree (`FormattingInlineNode` + `TextInlineNode`). - `ValueNode`: non-text typed value for tools. -- `BlockTune`: per-block tune configuration. +- `PluginDataNode`: per-block data owned by a plugin, keyed by the plugin's `name`. ## Mutation and event invariant @@ -41,7 +41,7 @@ IndexBase (abstract) ├── PropertyIndex — top-level document property ├── BlockIndex — a single block ├── DataIndex — a data key inside a block -├── TuneIndex — a key inside a block tune +├── PluginDataIndex — a key inside one plugin's per-block data └── TextIndex — character range(s) inside a text node ``` @@ -68,7 +68,7 @@ Index.document(documentId) Index.property(name, documentId?) Index.block(blockIndex, documentId?) Index.data(blockIndex, dataKey, documentId?) -Index.tune(blockIndex, tuneName, tuneKey, documentId?) +Index.pluginData(blockIndex, pluginName, pluginKey, documentId?) Index.text(segments) Index.parse(serialized) // deserialize from string ``` diff --git a/docs/plugins.md b/docs/plugins.md index 55156296..f8d5f4e0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,7 +2,7 @@ ## Package boundary -Tools and plugins should only depend on `@editorjs/sdk` — never on `@editorjs/model` or `@editorjs/model-types` directly. `sdk` re-exports every type a tool/plugin author needs (`Index`, event classes, `BlockTool`/`InlineTool`/`BlockTune` contracts, etc.); `model` is the engine implementation that `core` and `ot-server` orchestrate, and `model-types` is an internal foundation shared only by `model` and `sdk`. Neither is part of the stable, tool-facing API. +Tools and plugins should only depend on `@editorjs/sdk` — never on `@editorjs/model` or `@editorjs/model-types` directly. `sdk` re-exports every type a tool/plugin author needs (`Index`, event classes, `BlockTool`/`InlineTool` contracts, etc.); `model` is the engine implementation that `core` and `ot-server` orchestrate, and `model-types` is an internal foundation shared only by `model` and `sdk`. Neither is part of the stable, tool-facing API. ## Registration @@ -26,7 +26,10 @@ Tools are registered via `core.use(ToolConstructor, options)` during setup. The | UI Plugin | `EditorjsPlugin` | UI component/behavior registered via `core.use(...)` | | Block Tool | `BlockTool` (from config `tools`) | Block rendering and block-specific behavior | | Inline Tool | `InlineTool` (from config `tools`) | Selection formatting actions | -| Block Tune | `BlockTune` (from config `tools`) | Per-block tune behavior | + +There is no Block Tune tool kind. What v2 called a tune is a UI plugin: it puts an item in the +[block settings menu](#block-settings) and stores its state as +[per-block plugin data](#per-block-plugin-data). ## Initialization sequence @@ -114,11 +117,112 @@ Merging is shallow **at the plugin-id level**: a slice supplied through `use()` const { shortcut } = toolFacade.pluginOptions('shortcuts') ?? {}; ``` +## Block settings + +The per-block settings menu — the thing v2 called "block tunes" — is no longer a kind of entity. +`BlockSettingsUI` renders the popover and the button that opens it, announcing both through +`ui:block-settings:rendered` for the toolbar to place, and owns no items of its own; a plugin +contributes to it by registering a *provider*: + +```ts +const unregister = api.plugins['block-settings']?.register(ctx => ({ + title: 'Anchor', + icon: IconLink, + isActive: Boolean(api.blocks.getPluginData({ block: ctx.blockId, plugin: 'anchors' })), + onActivate: () => setAnchor(ctx.blockId), +})); +``` + +A provider is asked again every time the menu opens, so an item's `isActive`, `isDisabled` and +`title` can be derived from current state rather than cached. Return `undefined` (or an empty +array) to contribute nothing for a given block — a provider that opts out adds no separator +either. Item behaviour comes entirely from the returned config — a `BlockSettingsMenuConfig`, which is +ui-kit's popover-item shape: `onActivate`, `closeOnActivate`, `children` for a submenu, or a +`confirmation` whose own handler runs on the second activation. It is deliberately not +`@editorjs/sdk`'s `MenuConfig`, which is the narrower shape inline tools return from +`getToolbarConfig`. + +The button that opens the menu is a toggle: activating it while the menu is open dismisses it. +It acts on the block the pointer is over, or — with no pointer involved — the one the caret is +in, so the menu is reachable from the keyboard alone. + +Contributions are concatenated in ascending `order` (default `0`, ties broken by registration +order), separated from one another. `@editorjs/default-block-settings-plugin` registers at +`order: 1000` so the built-in Move up / Move down / Delete sit last; it is the reference example +of a former "tune" written as a plain plugin, with no privileged path into the menu. + +### Resolve positions from `blockId`, not `blockIndex` + +The provider context carries both, but only `blockId` is durable: + +```ts +onActivate: () => { + const index = api.blocks.getIndexById(ctx.blockId); + + // The block may be gone — removed by a collaborator, or by an undo — while the menu was open. + if (index === -1) { + return; + } + + api.blocks.move({ fromIndex: index, toIndex: index - 1 }); +} +``` + +`ctx.blockIndex` is only true at the moment the menu was built. A menu can stay open while a +collaborator inserts a block above the target, or while an undo runs, and a handler acting on the +stale index then moves the wrong block. Deriving a *disabled state* from the index is fine, since +going stale only greys an item out — deriving an *action* from it is not. + +The menu is absent in a headless setup, which is why the lookup above is optional: a plugin +registered on a `Core` without `BlockSettingsUI` finds nothing under `api.plugins['block-settings']` +and should simply contribute nothing rather than fail. + +## Per-block plugin data + +A plugin stores its own data on a block through `api.blocks`, under a key that is by convention the +plugin's `name`. The entry is created on first write, so there is nothing to initialize: + +```ts +api.blocks.updatePluginData({ block: blockId, plugin: 'anchors', data: { id: 'intro' } }); + +api.blocks.getPluginData({ block: blockId, plugin: 'anchors' }); // → { id: 'intro' } +``` + +Declare the shape once and both calls are typed, with a wrong value a compile error: + +```ts +declare module '@editorjs/sdk' { + interface EditorjsPluginDataMap { + anchors: { id: string }; + } +} +``` + +A plugin that never augments the map still works: its data is typed as `Record`. + +Things worth knowing before relying on it: + +- **A key set to `undefined` is removed**, and an entry with no keys left is dropped from the + serialized block. So `getPluginData` returns `undefined` both for a plugin that never wrote + anything and for one whose last key was removed. +- **Each key is its own change.** Writing two keys in one call produces two modifications, so one + undo reverts only the last of them. Write a single key when the change must be atomic. +- **Concurrent writes to the same key resolve last-writer-wins.** Two collaborators toggling + different keys of the same plugin do not collide; two writing the same key do, and the operation + the server orders last wins. Split state across keys when that matters. +- **Data for an unregistered plugin is preserved**, not dropped, so opening a document in an editor + without that plugin does not lose it. +- **Blocks keep their data through `move` and `convert`**; a `split` leaves it on the original block + and gives the new one none. +- **v2 documents carry over**: a v2 block's `tunes` are mapped onto the same keys on load, and a + tune whose data was not an object lands under a `value` key. + ## 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 +Three features — calling a plugin API, configuring a plugin from a tool, and storing per-block +plugin data — are typed the same way: `@editorjs/sdk` declares three empty interfaces +(`EditorjsPluginApiMap`, `ToolPluginOptionsMap`, `EditorjsPluginDataMap`) 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 @@ -267,6 +371,11 @@ Programmatic block management — delegates to `BlocksManager`. | `render(document)` | Re-initialize the document from serialised data | | `clear()` | Remove all blocks | | `getBlocksCount()` | Return the total number of blocks | +| `getIdByIndex(index)` | Id of the block at that index, or `undefined` | +| `getIndexById(id)` | Position of the block with that id, or `-1` | +| `getToolByIndex(index)` | Tool name of the block at that index, or `undefined` | +| `getPluginData({ block, plugin })` | Read a plugin's per-block data, or `undefined` | +| `updatePluginData({ block, plugin, data, userId? })` | Merge keys into a plugin's per-block data | ### `api.selection` diff --git a/openspec/changes/block-settings-ui/design.md b/openspec/changes/block-settings-ui/design.md index ef8018c7..c5dfa4b2 100644 --- a/openspec/changes/block-settings-ui/design.md +++ b/openspec/changes/block-settings-ui/design.md @@ -44,13 +44,17 @@ type BlockSettingsProvider = (ctx: { blockId: BlockId; blockIndex: number; tool: Wiring follows the Toolbox pattern, with one departure from it: 1. `BlockSettingsUI` renders both the popover **and the button that opens it**, and announces the two together via `ui:block-settings:rendered`. `ToolbarUI` mounts them in its actions area and reassigns its single tab stop once the button joins. -2. The plugin follows `ui:blocks:block-selected` itself to know which block the button acts on, and stops following it while the menu is open. It also opens on a `ui:block-settings:open` event, so a host can request the menu for a block without the button. -3. `ToolbarUI` doesn't reposition while settings are open. +2. The plugin follows `ui:blocks:block-selected` itself to know which block the button acts on, and stops following it for as long as the toolbar is holding its position -- while the menu is open, and while the toolbox is open. It also opens on a `ui:block-settings:open` event, so a host can request the menu for a block without the button. +3. `ToolbarUI` doesn't reposition while settings or the toolbox are open. + +The two halves of 2 and 3 have to agree, which is why the plugin tracks `ui:toolbox:opened`/`closed` as well. `ToolbarUI` owns where the button sits and this plugin owns what it acts on, so whenever one freezes the other must: a button left beside one block whose menu was built for another acts on the wrong block, and `Delete` is one of the items. *Why the button belongs to the plugin rather than the toolbar:* a menu button's `aria-expanded` has to track the menu, and the menu's open state is the plugin's. Split across the two, every open and close would need a round trip through the event bus to keep an attribute honest, and the button would report a block the menu was not built for. `ToolbarUI` treats it as a control of the container like its own plus button -- named, stated and operated by its owner, positioned and tab-managed by the toolbar. `BlockSettingsUI` resolves the target block's id with `api.blocks.getIdByIndex(index)` from the index it last saw selected, so this change needs no new event or payload field from `core`. +*Alternative considered:* give `ToolbarUI` the target index and pass it in `ui:block-settings:open`, so there is one owner rather than two components agreeing. Rejected for now because it moves the block the menu acts on back out of the plugin that owns the button, against the reasoning above; the duplicated flag is the smaller cost, and `ui:block-settings:open` stays available as the seam if that trade changes. + The provider context carries both `blockId` and `blockIndex`, but only `blockId` is durable: a menu can stay open while a collaborator inserts or removes blocks above the target, or while an undo does. Item handlers therefore resolve the position from `blockId` at activation time. This is how the tunes in PR #157 behave (`getIndexById` inside `activate()`), and it is worth keeping. ### D2. Built-in actions are a plugin package in `packages/plugins/` diff --git a/openspec/changes/block-settings-ui/specs/block-settings/spec.md b/openspec/changes/block-settings-ui/specs/block-settings/spec.md index 249254d0..6f2f0ce7 100644 --- a/openspec/changes/block-settings-ui/specs/block-settings/spec.md +++ b/openspec/changes/block-settings-ui/specs/block-settings/spec.md @@ -24,7 +24,7 @@ The system SHALL provide `BlockSettingsUI`, an `EditorjsPlugin` with static `nam - **THEN** no provider is invoked, the popover is not opened, and no error is thrown ### Requirement: Block settings button -The button `BlockSettingsUI` announces SHALL carry the whole menu-button contract, because the state it reports is the plugin's own: an accessible name from the UI package's message catalogue, `aria-haspopup="menu"`, and an `aria-expanded` kept in sync with whether the menu is open. Activating it SHALL focus it before opening the menu, so the popover has a control to return focus to when it closes. Activating it while the menu is already open SHALL close the menu instead of rebuilding it, as the menu button pattern requires: the control that opens a menu is also the control that dismisses it. This SHALL hold however the button is activated, pointer or keyboard, which means the state it reads cannot be captured from pointer events alone. The button SHALL also declare itself a non-submitting control, so that an editor mounted inside a form does not post it when the menu is opened. The plugin SHALL follow block selection to know which block the button acts on, and SHALL stop following it while the menu is open, so the open menu and the button cannot come to mean different blocks. +The button `BlockSettingsUI` announces SHALL carry the whole menu-button contract, because the state it reports is the plugin's own: an accessible name from the UI package's message catalogue, `aria-haspopup="menu"`, and an `aria-expanded` kept in sync with whether the menu is open. Activating it SHALL focus it before opening the menu, so the popover has a control to return focus to when it closes. Activating it while the menu is already open SHALL close the menu instead of rebuilding it, as the menu button pattern requires: the control that opens a menu is also the control that dismisses it. This SHALL hold however the button is activated, pointer or keyboard, which means the state it reads cannot be captured from pointer events alone. Where the open menu's own keyboard handling claims the key and suppresses the click the activation would otherwise produce, the dismissal SHALL happen on the keydown itself: no click arrives to carry it, and without that the menu cannot be dismissed from the button at all. The button SHALL also declare itself a non-submitting control, so that an editor mounted inside a form does not post it when the menu is opened. The plugin SHALL follow block selection to know which block the button acts on, and SHALL stop following it whenever the toolbar is holding its position — while the menu is open, and while the toolbox is open, since that popover renders inside the toolbar and moving it would carry the popover along. The button therefore stays beside one block for as long as either is open, and the block it acts on SHALL stay with it: a menu built for a block the button is not next to acts on the wrong one, and `Delete` is among its items. #### Scenario: The button is announced with the menu - **WHEN** `BlockSettingsUI` is constructed @@ -45,6 +45,16 @@ The button `BlockSettingsUI` announces SHALL carry the whole menu-button contrac - **WHEN** the button is activated again - **THEN** the menu closes, `aria-expanded` returns to `"false"`, and no second menu is built +#### Scenario: The button is activated again from the keyboard +- **GIVEN** the menu was opened from the button, and the button holds focus +- **WHEN** Enter is pressed again, and the open menu's keyboard handling has already prevented the default so that no click follows +- **THEN** the menu closes and `aria-expanded` returns to `"false"`, rather than staying open with nothing left to dismiss it + +#### Scenario: A claimed key arrives with the menu closed +- **GIVEN** the menu is closed +- **WHEN** a key whose default was prevented elsewhere reaches the button +- **THEN** no provider is asked and no menu opens, because a dismissal is all that keydown stands in for + #### Scenario: The menu is dismissed while a replacement is being built - **GIVEN** the menu is open and another block's menu has been requested, whose providers have not returned - **WHEN** the open menu is dismissed, by Escape or by a click outside @@ -55,6 +65,16 @@ The button `BlockSettingsUI` announces SHALL carry the whole menu-button contrac - **WHEN** another block is reported as selected - **THEN** that build is abandoned, rather than opening one block's actions beside another +#### Scenario: The target does not move while the toolbox is open +- **GIVEN** the block at index 0 is selected and the toolbox is open, so the toolbar is holding its position beside that block +- **WHEN** another block is reported as selected, the toolbox closes, and the button is activated +- **THEN** the menu is built for the block the button is beside, not the one hovered while the toolbox was open + +#### Scenario: The target follows the selection again once the toolbox closes +- **GIVEN** the toolbox was open and has closed +- **WHEN** another block is reported as selected and the button is activated +- **THEN** the menu is built for that block, because the toolbar is following the selection again + #### Scenario: The target does not move under an open menu - **GIVEN** the menu is open for the block at index 0 - **WHEN** another block is reported as selected and the menu is then closed and opened again @@ -111,13 +131,28 @@ The settings popover SHALL carry an accessible name drawn from the UI package's - **THEN** the item switches to its confirmation state, and only activating the confirmation runs the handler declared inside `confirmation` ### Requirement: A failing provider costs only its own items -Providers are third-party code, and the open menu is the one place all of it runs, so no single provider SHALL be able to stop the menu from opening. A provider that throws, whose promise rejects, or that has not settled within a fixed timeout SHALL contribute nothing, SHALL be reported to the console so the plugin behind it can be identified, and SHALL leave no separator behind; every other provider's contribution is still built and shown. A contribution SHALL be discarded rather than rendered when a newer open request has started since it was asked for, or when the menu it was being built for has been closed in the meantime. +Providers are third-party code, and the open menu is the one place all of it runs, so no single provider SHALL be able to stop the menu from opening. A provider that throws, whose promise rejects, or that has not settled within a fixed timeout SHALL contribute nothing, SHALL be reported to the console so the plugin behind it can be identified, and SHALL leave no separator behind; every other provider's contribution is still built and shown. This SHALL cover the callbacks a contributed item carries that the popover calls as it renders — `isActive`, at any depth of nested items and on an item's confirmation state — which run after the provider has returned and outside anything it could be held to otherwise: a callback throwing there SHALL cost that provider's items, as returning nothing does, rather than aborting the popover and with it every other provider's items. A contribution SHALL be discarded rather than rendered when a newer open request has started since it was asked for, or when the menu it was being built for has been closed in the meantime. #### Scenario: A provider throws - **GIVEN** two providers are registered and the first throws when invoked - **WHEN** block settings open - **THEN** the menu opens with the second provider's items alone, with no separator for the first, and the failure is reported to the console +#### Scenario: An item's isActive callback throws +- **GIVEN** two providers are registered and the first contributes an item whose `isActive` throws +- **WHEN** block settings open +- **THEN** the menu opens with the second provider's items alone and the failure is reported to the console, rather than no menu opening at all + +#### Scenario: A nested item's isActive callback throws +- **GIVEN** a provider contributes an item whose nested child's `isActive` throws +- **WHEN** block settings open +- **THEN** that provider is skipped on the same terms, because nested items are rendered with their parent + +#### Scenario: An isActive callback that works +- **GIVEN** a provider contributes one item whose `isActive` returns `true` and one whose returns `false` +- **WHEN** block settings open +- **THEN** the first is shown as active and the second is not + #### Scenario: A provider's promise rejects - **GIVEN** a provider returns a promise that rejects - **WHEN** block settings open diff --git a/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index eab6903a..e3587cb6 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -15,17 +15,18 @@ ## 1. UI test setup -- [ ] 1.1 Add Jest to `packages/ui`: `jest.config.ts` with `testEnvironment: 'jsdom'`, a `moduleNameMapper` for `\.pcss$` and the `@codexteam/ui/styles*` side-effect imports, `transformIgnorePatterns` covering `@codexteam/*` as well as `@editorjs/*`, and the `test`/`test:coverage` scripts using `node --experimental-vm-modules` -- [ ] 1.2 Add the dev dependencies: `jest`, `jest-environment-jsdom` (no package uses jsdom yet, so it must be explicit), `ts-jest`, `babel-jest`, `@babel/core`, `@babel/preset-env`, `@jest/globals`, `@types/jest`, `ts-node` -- [ ] 1.3 Add a `files: ['**/*.spec.ts']` override to `packages/ui/eslint.config.mjs` allowing `@jest/globals`, mirroring `packages/core/eslint.config.mjs`, or `yarn lint` fails on the new specs -- [ ] 1.4 Point `vite.config.ts`'s `dts()` at `tsconfig.build.json` so declaration emit keeps excluding `*.spec.ts` -- [ ] 1.5 Add `.github/workflows/ui.yml` following `.github/workflows/core.yml`, and handle the first-run `base-coverage` step, which checks out the base ref and runs `test:coverage` for a package that has no such script there -- [ ] 1.6 Hand-edit the note in `openspec/specs/ui/spec.md`'s preamble that says the package has no automated test suite — it sits outside any `### Requirement:` block, so the archive fold will not rewrite it +- [x] 1.1 Add Jest to `packages/ui`: `jest.config.ts` with `testEnvironment: 'jsdom'`, a `moduleNameMapper` for `\.pcss$` and the `@codexteam/ui/styles*` side-effect imports, `transformIgnorePatterns` covering `@codexteam/*` as well as `@editorjs/*`, and the `test`/`test:coverage` scripts using `node --experimental-vm-modules` +- [x] 1.2 Add the dev dependencies: `jest`, `jest-environment-jsdom` (no package uses jsdom yet, so it must be explicit), `ts-jest`, `babel-jest`, `@babel/core`, `@babel/preset-env`, `@jest/globals`, `@types/jest`, `ts-node` +- [x] 1.3 Add a `files: ['**/*.spec.ts']` override to `packages/ui/eslint.config.mjs` allowing `@jest/globals`, mirroring `packages/core/eslint.config.mjs`, or `yarn lint` fails on the new specs +- [x] 1.4 Point `vite.config.ts`'s `dts()` at `tsconfig.build.json` so declaration emit keeps excluding `*.spec.ts` +- [x] 1.5 Add `.github/workflows/ui.yml` following `.github/workflows/core.yml`, and handle the first-run `base-coverage` step, which checks out the base ref and runs `test:coverage` for a package that has no such script there + - `base-coverage` already skipped a package absent from the base ref, but `packages/ui/package.json` *is* there — only the script is missing, so the file test passed and `yarn workspace @editorjs/ui test:coverage` would have failed with "couldn't find a script". Its guard now also checks that the base ref declares `test:coverage`, which covers every future package that adds its first suite +- [x] 1.6 Hand-edit the note in `openspec/specs/ui/spec.md`'s preamble that says the package has no automated test suite — it sits outside any `### Requirement:` block, so the archive fold will not rewrite it ## 2. Block settings plugin -- [ ] 2.1 Port the `BlockTunes*UIEvent` classes from PR #157 as `BlockSettings{Open,Opened,Closed,Rendered}UIEvent` under `ui/src/BlockSettings/events`, using `ui:block-settings:*` names. Credit ported work with `Co-authored-by: Betty Steger <244475+bettysteger@users.noreply.github.com>` -- [ ] 2.2 Write failing `BlockSettings.spec.ts` cases: +- [x] 2.1 Port the `BlockTunes*UIEvent` classes from PR #157 as `BlockSettings{Open,Opened,Closed,Rendered}UIEvent` under `ui/src/BlockSettings/events`, using `ui:block-settings:*` names. Credit ported work with `Co-authored-by: Betty Steger <244475+bettysteger@users.noreply.github.com>` +- [x] 2.2 Write failing `BlockSettings.spec.ts` cases: - the target block's id should be resolved from the reported index via `api.blocks.getIdByIndex` - providers should be called with `{ blockId, blockIndex, tool }` on each open - results should be ordered by `order` then registration, with separators between providers @@ -34,18 +35,20 @@ - no popover should open when there are no items - `close()` should dispatch closed - the context handed to providers should carry `blockId`, and the spec's rule that handlers resolve positions from it should be covered by the default-block-settings tests -- [ ] 2.3 Add a `UiComponentType.BlockSettings` member in `sdk` and declare it as `BlockSettingsUI`'s `static type` (`EditorjsPluginConstructor` requires an `EntityType` value) -- [ ] 2.4 Implement `BlockSettingsUI` (`static name = 'block-settings'`, `publicApi: { register, close }`) on a ui-kit popover, porting its setup from PR #157's `BlockTunesUI` (`scopeElement: config.holder`, `searchable: false`, `wrapperTag: 'button'`, closed event from `PopoverEvent.Closed`, element handed over via the rendered event) -- [ ] 2.5 Write failing tests, then give the popover an accessible name from a new `messages.ts` entry, and make removed items leave the accessibility tree -- [ ] 2.6 Write a failing test, then make a request with no resolvable block id a no-op (no providers invoked, no popover, no throw) -- [ ] 2.7 Export `BlockSettingsUI` plus the `BlockSettingsAPI`/`BlockSettingsProvider` types from `@editorjs/ui`, and augment `EditorjsPluginApiMap` under `'block-settings'` there — `sdk` stays unaware of individual plugins +- [x] 2.3 Add a `UiComponentType.BlockSettings` member in `sdk` and declare it as `BlockSettingsUI`'s `static type` (`EditorjsPluginConstructor` requires an `EntityType` value) +- [x] 2.4 Implement `BlockSettingsUI` (`static name = 'block-settings'`, `publicApi: { register, close }`) on a ui-kit popover, porting its setup from PR #157's `BlockTunesUI` (`scopeElement: config.holder`, `searchable: false`, `wrapperTag: 'button'`, closed event from `PopoverEvent.Closed`, element handed over via the rendered event) +- [x] 2.5 Write failing tests, then give the popover an accessible name from a new `messages.ts` entry, and make removed items leave the accessibility tree + - The menu is rebuilt from scratch on each open rather than edited in place. `removeItemByName` is ui-kit's only removal and a separator is constructed without params, so it has no name to be removed by; handing a fresh popover the whole item list is what guarantees the previous block's items leave the tree. The swap happens inside the plugin's own holder, so ui-kit's rendered DOM is never touched +- [x] 2.6 Write a failing test, then make a request with no resolvable block id a no-op (no providers invoked, no popover, no throw) +- [x] 2.7 Export `BlockSettingsUI` plus the `BlockSettingsAPI`/`BlockSettingsProvider` types from `@editorjs/ui`, and augment `EditorjsPluginApiMap` under `'block-settings'` there — `sdk` stays unaware of individual plugins ## 3. Toolbar settings button -- [ ] 3.1 Port the settings button styles from PR #157 (`IconMenuSmall`, the `__settings-button` rule, and `display: flex; align-items: center` on `__actions`). The button itself is rendered by `BlockSettingsUI` and announced with the menu, so `ToolbarUI` mounts it **before** the roving-tabindex initialization and reassigns its single tab stop once it joins -- [ ] 3.2 Take PR #157's fix for `new ToolboxOpenUIEvent('ui:toolbox:open')` passing a string instead of a payload object (`packages/ui/src/Toolbar/Toolbar.ts:151`), with a test that the dispatched event carries an object payload -- [ ] 3.3 Track the selected block index in `BlockSettingsUI` from `ui:blocks:block-selected` (the toolbar's handler reads only `event.detail.block`), following `ToolboxUI`'s `#selectedBlockIndex`. It belongs to the plugin that owns the button, so the button and the menu cannot come to mean different blocks -- [ ] 3.4 Write failing cases, in `Toolbar.spec.ts` for what the toolbar owns and in `BlockSettings.spec.ts` for what the plugin owns: +- [x] 3.1 Port the settings button styles from PR #157 (`IconMenuSmall`, the `__settings-button` rule, and `display: flex; align-items: center` on `__actions`). The button itself is rendered by `BlockSettingsUI` and announced with the menu, so `ToolbarUI` mounts it **before** the roving-tabindex initialization and reassigns its single tab stop once it joins +- [x] 3.2 Take PR #157's fix for `new ToolboxOpenUIEvent('ui:toolbox:open')` passing a string instead of a payload object (`packages/ui/src/Toolbar/Toolbar.ts:151`), with a test that the dispatched event carries an object payload + - The string type-checked because the payload interface is empty, so every listener had been receiving `detail: 'ui:toolbox:open'`. The event name was never affected -- it comes from the class -- so nothing downstream had to change with it +- [x] 3.3 Track the selected block index in `BlockSettingsUI` from `ui:blocks:block-selected` (the toolbar's handler reads only `event.detail.block`), following `ToolboxUI`'s `#selectedBlockIndex`. It belongs to the plugin that owns the button, so the button and the menu cannot come to mean different blocks +- [x] 3.4 Write failing cases, in `Toolbar.spec.ts` for what the toolbar owns and in `BlockSettings.spec.ts` for what the plugin owns: - both announced elements should be mounted on `ui:block-settings:rendered`, the button as a direct child of the actions container - the toolbar should not reposition while settings are open - exactly one control of the actions container should have `tabindex="0"` after the button joins @@ -53,51 +56,62 @@ - `aria-expanded` should follow the plugin's own open state - the button should be focused before the popover opens - activating the button again should close the menu rather than rebuild it -- [ ] 3.5 Implement the behavior, splitting it as above: mounting, positioning and the tab stop in `ToolbarUI`, the button and its state in `BlockSettingsUI` +- [x] 3.5 Implement the behavior, splitting it as above: mounting, positioning and the tab stop in `ToolbarUI`, the button and its state in `BlockSettingsUI` ## 4. Default block settings plugin -- [ ] 4.1 Scaffold `packages/plugins/default-block-settings-plugin` as a full copy of `shortcuts-plugin`'s layout: `package.json` (the same `build`/`build:declaration`/`lint`/`lint:ci`/`lint:fix`/`test`/`test:coverage`/`test:mutations`/`clear` scripts), `tsconfig.json`, `tsconfig.build.json`, `tsconfig.eslint.json`, `eslint.config.mjs`, `jest.config.ts`, `stryker.conf.mjs`, `.gitignore`, `README.md`, `src/index.ts`, `src/index.spec.ts` -- [ ] 4.2 Add `.github/workflows/default-block-settings-plugin.yml` following `shortcuts-plugin.yml` — without it the package is never linted, tested or built in CI -- [ ] 4.3 Depend on `@editorjs/sdk` at runtime and on `@editorjs/ui` as a devDependency for `import type` only, so `api.plugins['block-settings']` typechecks without runtime coupling -- [ ] 4.4 Write failing tests: +- [x] 4.1 Scaffold `packages/plugins/default-block-settings-plugin` as a full copy of `shortcuts-plugin`'s layout: `package.json` (the same `build`/`build:declaration`/`lint`/`lint:ci`/`lint:fix`/`test`/`test:coverage`/`test:mutations`/`clear` scripts), `tsconfig.json`, `tsconfig.build.json`, `tsconfig.eslint.json`, `eslint.config.mjs`, `jest.config.ts`, `stryker.conf.mjs`, `.gitignore`, `README.md`, `src/index.ts`, `src/index.spec.ts` +- [x] 4.2 Add `.github/workflows/default-block-settings-plugin.yml` following `shortcuts-plugin.yml` — without it the package is never linted, tested or built in CI +- [x] 4.3 Depend on `@editorjs/sdk` at runtime and on `@editorjs/ui` as a devDependency for `import type` only, so `api.plugins['block-settings']` typechecks without runtime coupling + - `@codexteam/icons` turned out to be a second runtime dependency: the items carry icons, and the package was resolving it through workspace hoisting alone, which would break a standalone install. Declared it, and widened the capability's requirement text, which had said `@editorjs/sdk` was the only one +- [x] 4.4 Write failing tests: - should register at `order: 1000` on `core:ready` - should stay inert without `block-settings` - move up/down should call `api.blocks.move` and be disabled at the boundaries - handlers should resolve the block's index from `ctx.blockId` at activation, so an insertion above the block while the menu is open still moves the intended block - actions should do nothing when the target block no longer exists - delete should use `confirmation` and call `api.blocks.delete` by block id only on confirm -- [ ] 4.5 Implement `DefaultBlockSettingsPlugin`, porting behavior and test cases from PR #157's internal delete/move-up/move-down tunes (keeping their `getIndexById`-at-activation approach) and adding the delete confirmation they lack. Credit with the `Co-authored-by` line from 2.1 -- [ ] 4.6 Register `BlockSettingsUI` and `DefaultBlockSettingsPlugin` in `@editorjs/editorjs`: add `"@editorjs/default-block-settings-plugin": "workspace:^"` to its `package.json`, update the lockfile, extend the `@editorjs/ui` mock factory in `editorjs/src/index.spec.ts` with `BlockSettingsUI`, add a `jest.unstable_mockModule` for the new package, and note that its assertions read `ctor.name` — which for `static name = 'default-block-settings'` is `'default-block-settings'`, not the class name -- [ ] 4.7 Hand-edit `openspec/specs/editorjs-bundle/spec.md`'s Purpose preamble, which enumerates the default plugins — outside any requirement block, so the fold will not rewrite it -- [ ] 4.8 Add `packages/editorjs/e2e/tests/block-settings.spec.ts` using the harness's `mountDocument(page, '?text=Alpha&text=Beta')` fixture: open settings on the second block, move it up, then delete it with confirmation. Confirm `include-e2e: true` is set for the bundle workflow +- [x] 4.5 Implement `DefaultBlockSettingsPlugin`, porting behavior and test cases from PR #157's internal delete/move-up/move-down tunes (keeping their `getIndexById`-at-activation approach) and adding the delete confirmation they lack. Credit with the `Co-authored-by` line from 2.1 +- [x] 4.6 Register `BlockSettingsUI` and `DefaultBlockSettingsPlugin` in `@editorjs/editorjs`: add `"@editorjs/default-block-settings-plugin": "workspace:^"` to its `package.json`, update the lockfile, extend the `@editorjs/ui` mock factory in `editorjs/src/index.spec.ts` with `BlockSettingsUI`, add a `jest.unstable_mockModule` for the new package, and note that its assertions read `ctor.name` — which for `static name = 'default-block-settings'` is `'default-block-settings'`, not the class name +- [x] 4.7 Hand-edit `openspec/specs/editorjs-bundle/spec.md`'s Purpose preamble, which enumerates the default plugins — outside any requirement block, so the fold will not rewrite it +- [x] 4.8 Add `packages/editorjs/e2e/tests/block-settings.spec.ts` using the harness's `mountDocument(page, '?text=Alpha&text=Beta')` fixture: open settings on the second block, move it up, then delete it with confirmation. Confirm `include-e2e: true` is set for the bundle workflow ## 5. Docs and wrap-up -- [ ] 5.1 Update `docs/plugins.md` (the block settings API and default-block-settings as the reference "tune") and `docs/events.md` (the `ui:block-settings:*` events) -- [ ] 5.2 Run `yarn lint`, `yarn test`, and the e2e suite. Fix any regressions +- [x] 5.1 Update `docs/plugins.md` (the block settings API and default-block-settings as the reference "tune") and `docs/events.md` (the `ui:block-settings:*` events) +- [x] 5.2 Run `yarn lint`, `yarn test`, and the e2e suite. Fix any regressions + - Clean: lint and build workspace-wide, 1,099 unit tests, and 92 e2e across Chromium and WebKit. The one `✘` in the e2e run is `aria.spec.ts`'s `test.fail()` case for the unfixed `focus: true` caret todo, which Playwright counts as passing - [ ] 5.3 Run `openspec validate block-settings-ui --type change` and confirm it passes. After archiving, fill the `## Purpose` of the new `block-settings` and `default-block-settings-plugin` specs from the proposal's intended-Purpose text ## 6. Review follow-ups -- [ ] 6.1 Write a failing `Blocks.spec.ts` case, then dispatch `BlockSelectedUIEvent` for the caret moving into a block as well as for `mouseenter`. Without it `#selectedBlockIndex` stays `-1` for anyone who never moves a pointer, and the settings button is a control that silently does nothing. Read the caret from the document selection: the editor's own caret state is cleared when focus leaves the editable, which is what reaching for the button does -- [ ] 6.2 Write a failing case, then make the settings button close an open menu instead of rebuilding it, per the menu button pattern. ui-kit closes the popover before the button's own handler runs, so the state has to be read on `mousedown` and tracked by the plugin. Cover it in the e2e suite too, since what is being asserted is a second real click -- [ ] 6.3 Add `getToolByIndex` to `BlocksAPI` (core + sdk) and read the provider context's `tool` through it. `api.document.data` serializes the whole document, and the menu was paying that on every open -- [ ] 6.4 Replace the two `api.document.data.blocks.length` reads in `DefaultBlockSettingsPlugin` with `api.blocks.getBlocksCount()`, for the same reason -- [ ] 6.5 Rename the UI package's `MenuConfig` to `BlockSettingsMenuConfig`: `@editorjs/sdk` already exports an unrelated `MenuConfig`, and the two are not interchangeable -- [ ] 6.6 Make `BlockSettingsUI`, `DefaultBlockSettingsPlugin` and `ToolbarUI` drop their event bus listeners in `destroy()`, and stop the plugin registering a provider after it has been destroyed -- [ ] 6.7 Restore `packages/ui`'s `build:declaration` to a script that emits declarations, now that it has dependencies to build first +- [x] 6.1 Write a failing `Blocks.spec.ts` case, then dispatch `BlockSelectedUIEvent` for the caret moving into a block as well as for `mouseenter`. Without it `#selectedBlockIndex` stays `-1` for anyone who never moves a pointer, and the settings button is a control that silently does nothing. Read the caret from the document selection: the editor's own caret state is cleared when focus leaves the editable, which is what reaching for the button does +- [x] 6.2 Write a failing case, then make the settings button close an open menu instead of rebuilding it, per the menu button pattern. ui-kit closes the popover before the button's own handler runs, so the state has to be read on `mousedown` and tracked by the plugin. Cover it in the e2e suite too, since what is being asserted is a second real click +- [x] 6.3 Add `getToolByIndex` to `BlocksAPI` (core + sdk) and read the provider context's `tool` through it. `api.document.data` serializes the whole document, and the menu was paying that on every open +- [x] 6.4 Replace the two `api.document.data.blocks.length` reads in `DefaultBlockSettingsPlugin` with `api.blocks.getBlocksCount()`, for the same reason +- [x] 6.5 Rename the UI package's `MenuConfig` to `BlockSettingsMenuConfig`: `@editorjs/sdk` already exports an unrelated `MenuConfig`, and the two are not interchangeable +- [x] 6.6 Make `BlockSettingsUI`, `DefaultBlockSettingsPlugin` and `ToolbarUI` drop their event bus listeners in `destroy()`, and stop the plugin registering a provider after it has been destroyed +- [x] 6.7 Restore `packages/ui`'s `build:declaration` to a script that emits declarations, now that it has dependencies to build first ## 7. Review follow-ups (second round) -- [ ] 7.1 Write a failing `BlockSettings.spec.ts` case for a provider whose promise never settles, then race each provider against a timeout and route the expiry into the existing skip-and-log path. `#collectItems` awaits `Promise.all`, so one hanging provider means the menu never opens — and never opens again, because every later attempt waits on the same provider -- [ ] 7.2 Write a failing case, then make `#close()` abandon a build in flight by bumping `#openToken` before its open-state guard. A `close()` during the build window is currently ignored, because `#isOpen` is only set once the providers have returned, and the menu then opens after the close -- [ ] 7.3 Reconcile `design.md` with the specs on who owns the settings button: the specs and the code give the button, its ARIA state and the block it acts on to `BlockSettingsUI`, while the design doc still has `ToolbarUI` owning them and dispatching `ui:block-settings:open`. Rename its remaining `MenuConfig` references to `BlockSettingsMenuConfig` +- [x] 7.1 Write a failing `BlockSettings.spec.ts` case for a provider whose promise never settles, then race each provider against a timeout and route the expiry into the existing skip-and-log path. `#collectItems` awaits `Promise.all`, so one hanging provider means the menu never opens — and never opens again, because every later attempt waits on the same provider +- [x] 7.2 Write a failing case, then make `#close()` abandon a build in flight by bumping `#openToken` before its open-state guard. A `close()` during the build window is currently ignored, because `#isOpen` is only set once the providers have returned, and the menu then opens after the close +- [x] 7.3 Reconcile `design.md` with the specs on who owns the settings button: the specs and the code give the button, its ARIA state and the block it acts on to `BlockSettingsUI`, while the design doc still has `ToolbarUI` owning them and dispatching `ui:block-settings:open`. Rename its remaining `MenuConfig` references to `BlockSettingsMenuConfig` ## 8. Review follow-ups (third round) -- [ ] 8.1 Write a failing case, then abandon a build in flight from `#handleClosed` as well as `#close()`. Escape and a click outside reach it straight from the popover, so a menu dismissed while a replacement was being built reappeared when that replacement's providers returned -- [ ] 8.2 Write a failing case, then abandon a build when the selection leaves the block it is for. `#isOpen` is false until the providers return, so selection is still being followed, and the build would open one block's actions beside another -- [ ] 8.3 Write a failing case, then record the toggle's state on the `keydown` of Enter and Space as well as on `mousedown`. A keyboard activation sends a `click` with no pointer events, so the snapshot always said "closed". Note in the PR that the browser path is blocked earlier: ui-kit's flipper calls `preventDefault()` on that Enter, so no click is produced at all and the menu cannot be dismissed from the button — that half belongs upstream -- [ ] 8.4 Give the settings button `type="button"`, so an editor mounted inside a form is not submitted when the menu opens -- [ ] 8.5 Write a failing `Blocks.spec.ts` case, then stop deduplicating pointer entry, keeping it for caret moves. `mouseenter` only fires on crossing into a block, and suppressing a repeat stranded the toolbar and the settings menu, which both ignore selection while the menu is open +- [x] 8.1 Write a failing case, then abandon a build in flight from `#handleClosed` as well as `#close()`. Escape and a click outside reach it straight from the popover, so a menu dismissed while a replacement was being built reappeared when that replacement's providers returned +- [x] 8.2 Write a failing case, then abandon a build when the selection leaves the block it is for. `#isOpen` is false until the providers return, so selection is still being followed, and the build would open one block's actions beside another +- [x] 8.3 Write a failing case, then record the toggle's state on the `keydown` of Enter and Space as well as on `mousedown`. A keyboard activation sends a `click` with no pointer events, so the snapshot always said "closed". Note in the PR that the browser path is blocked earlier: ui-kit's flipper calls `preventDefault()` on that Enter, so no click is produced at all and the menu cannot be dismissed from the button — that half belongs upstream +- [x] 8.4 Give the settings button `type="button"`, so an editor mounted inside a form is not submitted when the menu opens +- [x] 8.5 Write a failing `Blocks.spec.ts` case, then stop deduplicating pointer entry, keeping it for caret moves. `mouseenter` only fires on crossing into a block, and suppressing a repeat stranded the toolbar and the settings menu, which both ignore selection while the menu is open + +## 9. Review follow-ups (fourth round) + +- [x] 9.1 Write a failing `BlockSettings.spec.ts` case for a block hovered while the toolbox is open, then stop following block selection for as long as the toolbox holds the toolbar in place, as the open menu already does. The toolbar freezes on `#isToolboxOpen` because the toolbox popover renders inside it; nothing told the settings plugin, so the button stayed beside one block while its target moved to another — and `Delete` then removed the block the user was not pointing at +- [x] 9.2 Write a failing case for an item whose `isActive` throws, then call those callbacks inside the provider's own `try`/`catch` and hand ui-kit the resolved values. ui-kit reads `isActive` while it renders the item, which happens in `#renderPopover`, long after `#collectItems` has returned: the throw aborted the popover, so one bad callback cost every provider's items and the menu did not open at all. Resolved at any depth of nested items and on an item's confirmation state, which render the same way; nothing is lost by resolving early, since the popover is rebuilt on every open + +## 10. Review follow-ups (fifth round) + +- [x] 10.1 Write a failing `BlockSettings.spec.ts` case for an Enter whose default was prevented before it reached the button, then dismiss the menu from the keydown handler when that is so, and restore the e2e case 8.3 dropped. 8.3 found that ui-kit's flipper claims the key for the open menu and suppresses the click, and concluded the remaining half belonged upstream — which was wrong: the handler still receives the keydown, and `defaultPrevented` identifies exactly the activation that will produce no click, so the dismissal has somewhere to live. A real-browser probe confirms the shape in both Chromium and WebKit: focus stays on the button, the keydown reaches it with the default already prevented in the capture phase, and no click ever arrives. The click stays the primary path, so the pointer and open-from-keyboard behaviour is untouched, and the unit helper that dispatches a keydown and a click together keeps meaning what it did diff --git a/openspec/changes/plugin-block-data/specs/collaboration-manager/spec.md b/openspec/changes/plugin-block-data/specs/collaboration-manager/spec.md index 620e0b6e..fe62278f 100644 --- a/openspec/changes/plugin-block-data/specs/collaboration-manager/spec.md +++ b/openspec/changes/plugin-block-data/specs/collaboration-manager/spec.md @@ -1,7 +1,7 @@ ## ADDED Requirements ### Requirement: Plugin data operations -`CollaborationManager` SHALL translate a `PluginDataModifiedEvent` into a `Modify` `Operation` on its `PluginDataIndex`, with `payload` set to the new value and `prevPayload` to the previous value. `OperationsTransformer` SHALL shift a plugin data operation's block index against block insertions and removals the same way it does for data index operations, SHALL turn it into a `Neutral` operation when its block is removed, and SHALL resolve two concurrent writes to the same block, plugin and key the same way it resolves two concurrent writes to the same data key: the operation being transformed becomes `Neutral` when the operation it is transformed against is the later of the two, an unsent local operation counting as later than any revision. Writes to different blocks, different plugins or different keys SHALL be left untransformed, since one cannot shift another's position. Transformation SHALL also accept a plugin data operation as the operation being transformed *against*, rather than rejecting its index kind. The `Modify` operation payload type SHALL admit any JSON value, including primitives. +`CollaborationManager` SHALL translate a `PluginDataModifiedEvent` into a `Modify` `Operation` on its `PluginDataIndex`, with `payload` set to the new value and `prevPayload` to the previous value. `OperationsTransformer` SHALL shift a plugin data operation's block index against block insertions and removals the same way it does for data index operations, SHALL turn it into a `Neutral` operation when its block is removed, and SHALL resolve two concurrent writes to the same block, plugin and key the same way it resolves two concurrent writes to the same data key: the operation being transformed becomes `Neutral` when the operation it is transformed against is the later of the two, a local operation the server has not acknowledged counting as later than any revision. A local operation SHALL count as unacknowledged for the whole round trip, not merely until it leaves for the server, so the client SHALL keep it among the operations an incoming one is transformed against until its acknowledgement arrives. The acknowledgement is what establishes the server's order: while it is outstanding, every remote operation the server delivers was ordered before the local one, and once it arrives, everything after it was ordered later. Writes to different blocks, different plugins or different keys SHALL be left untransformed, since one cannot shift another's position. An operation carrying no revision of its own SHALL lose to whatever it is transformed against. Such an operation comes from an undo or redo stack, whose entries are built from model events and are never numbered, while an operation from the server always is; and the operation it is transformed against has already been applied to the document. Keeping it would leave an undo step that writes a value the document has moved on from, and whose recorded previous value is older still, so undoing a local write that a collaborator had overwritten would delete the collaborator's value. Transformation SHALL also accept a plugin data operation as the operation being transformed *against*, rather than rejecting its index kind. The `Modify` operation payload type SHALL admit any JSON value, including primitives. #### Scenario: Local plugin data change becomes an operation - **GIVEN** the local user changes `anchors.visible` on block 1 from `true` to `false` @@ -35,9 +35,29 @@ #### Scenario: Two clients write the same key concurrently - **GIVEN** clients A and B both write `anchors.visible` on block 1, and the server applies A before B -- **WHEN** B receives A's operation while its own write is still unsent +- **WHEN** B receives A's operation while its own write is still unacknowledged - **THEN** A's operation is transformed to `Neutral` on B, so B keeps the value the server will end up holding rather than being left on A's +#### Scenario: A collaborator writes while the local write is in flight +- **GIVEN** the local client has sent a write of `anchors.color` and is waiting for its acknowledgement +- **WHEN** a collaborator's write to the same key arrives before that acknowledgement +- **THEN** it is transformed to `Neutral` and not applied, since the server will apply the local write after it -- and a client's own operations are not echoed back to it, so nothing would restore the value the server ends up holding + +#### Scenario: A collaborator writes once the local write is acknowledged +- **GIVEN** the local client's write of `anchors.color` has been acknowledged +- **WHEN** a collaborator's write to the same key arrives +- **THEN** it is applied, because the server ordered it after the local write + +#### Scenario: Undoing a write a collaborator overwrote +- **GIVEN** the local user wrote `anchors.color` on block 0, and a collaborator then overwrote the same key +- **WHEN** the undo stack is transformed against the collaborator's operation +- **THEN** the entry is dropped, so undo reaches past it rather than writing a value that is gone and deleting the collaborator's + +#### Scenario: Undoing a write to a key nobody else touched +- **GIVEN** the local user wrote `anchors.color` and a collaborator wrote `anchors.label` on the same block +- **WHEN** the undo stack is transformed against the collaborator's operation +- **THEN** the entry survives, because the two do not describe the same value + #### Scenario: Concurrent writes to different keys - **GIVEN** a plugin data operation on `anchors.visible` and a concurrent one on `anchors.id` of the same block - **WHEN** one is transformed against the other diff --git a/openspec/changes/plugin-block-data/specs/plugin-block-data/spec.md b/openspec/changes/plugin-block-data/specs/plugin-block-data/spec.md index 70671a29..8efc0fbe 100644 --- a/openspec/changes/plugin-block-data/specs/plugin-block-data/spec.md +++ b/openspec/changes/plugin-block-data/specs/plugin-block-data/spec.md @@ -9,6 +9,8 @@ Plugin names and the keys within an entry are document data, not identifiers the What `getPluginData` and the serialized document hand back SHALL be detached from the store, so that changing it does not change the document. Reading an entry, editing it and passing it back to `updatePluginData` is the obvious way to use the two together, and over a live record the no-op rule above turns it into silent loss: the document has already changed, so the write that should have announced it emits nothing. +A value handed to the store SHALL be detached the same way before it is stored and before the change is recorded, down to nested objects and arrays, and so SHALL the data a block is initialized with. This is the same read-modify-write from the other end: a plugin keeps the object it wrote and edits it again, and over a shared object the document moves with no write at all, no operation for collaborators and no step to undo — two documents diverging with nothing to say so. What is recorded needs detaching for the same reason, since an operation and an undo step both outlive the call that produced them. Because what is stored is therefore a copy of what was written, the no-op rule above SHALL compare values structurally rather than by identity, which could only ever match a primitive. + #### Scenario: A block with no plugin data - **GIVEN** a block that has no plugin data at all - **WHEN** it is serialized @@ -44,6 +46,26 @@ What `getPluginData` and the serialized document hand back SHALL be detached fro - **WHEN** `{ visible: undefined }` is written for `anchors` - **THEN** the data is unchanged and no `PluginDataModifiedEvent` is emitted +#### Scenario: Editing a value that was written earlier +- **GIVEN** a plugin wrote `{ align: "left" }` for a key and still holds that object +- **WHEN** it changes the object to `{ align: "center" }` and writes it again +- **THEN** the document held `left` until that write, which emits a `PluginDataModifiedEvent` with `previous` `{ align: "left" }` and `value` `{ align: "center" }` + +#### Scenario: Writing an equal object again +- **GIVEN** a key holds `{ align: "left" }` +- **WHEN** a different object holding `{ align: "left" }` is written for it +- **THEN** no `PluginDataModifiedEvent` is emitted, because nothing about the stored data changed + +#### Scenario: Editing an object after the change was recorded +- **GIVEN** a plugin wrote an object for a key, and the change was recorded +- **WHEN** it changes that object afterwards +- **THEN** the recorded value is unaffected, so neither the operation sent to collaborators nor the undo step is rewritten + +#### Scenario: Reusing the data a block was initialized with +- **GIVEN** a block is initialized with a `plugins` map whose nested values the caller still holds +- **WHEN** the caller changes one of them +- **THEN** the block's plugin data is unaffected + #### Scenario: Reading an entry whose keys were all removed - **GIVEN** a block whose `anchors` entry existed and then had every key removed - **WHEN** its plugin data is read diff --git a/openspec/changes/plugin-block-data/tasks.md b/openspec/changes/plugin-block-data/tasks.md index 12e0dbc0..f13451db 100644 --- a/openspec/changes/plugin-block-data/tasks.md +++ b/openspec/changes/plugin-block-data/tasks.md @@ -1,70 +1,81 @@ ## 1. Model types: rename tune → plugin data -- [ ] 1.1 Write failing `Index.spec.ts` cases: `Index.pluginData(1, 'anchors', 'id')` should serialize to `{"k":"plugin","b":1,"plugin":"anchors","key":"id"}`, `Index.parse` should round-trip it, and `PartialIndex#resolve` should throw `PluginDataIndex requires pluginKey` / `DataIndex cannot be combined with pluginName` -- [ ] 1.2 Rename `TuneIndex` → `PluginDataIndex` (`IndexKind.PluginData`; TypeScript members `pluginName`/`pluginKey`, serialized keys `plugin`/`key`), `Index.tune` → `Index.pluginData`, and update `PartialIndex` fields and error messages -- [ ] 1.3 Rename `BlockTuneName`/`createBlockTuneName`/`BlockTuneSerialized` → `PluginDataName`/`createPluginDataName`/`PluginDataSerialized` (`src/BlockTune.ts` → `src/PluginData.ts`) and `TuneModifiedEvent` → `PluginDataModifiedEvent`. Update `EventMap` (`BlockTuneEvents` → `PluginDataEvents`) and the exports in `index.ts` -- [ ] 1.4 Rename the `tunes` key to `plugins` in `BlockNodeSerialized`/`BlockNodeInit`/`BlockData`, and add a serialization test asserting no `tunes` key is produced -- [ ] 1.5 Update `sdk` re-exports of the renamed types and fix every compile error across the workspace (`yarn build` clean) -- [ ] 1.6 Update the jest mocks and fixtures the rename touches: `packages/model/src/entities/BlockNode/__mocks__/index.ts` (`updateTuneData`, plus a `plugins` getter if tests read it), `packages/model/src/entities/BlockTune/__mocks__`, the `jest.mock('../BlockTune')` path in `BlockNode.spec.ts`, and the `types/BlockTuneConstructorParameters.ts` / entities barrel names -- [ ] 1.7 Update `packages/model/src/EditorJSModel.spec.ts`'s hard-coded prototype-method allowlist, which contains `'updateTuneData'` as a string and so breaks with no compile error +- [x] 1.1 Write failing `Index.spec.ts` cases: `Index.pluginData(1, 'anchors', 'id')` should serialize to `{"k":"plugin","b":1,"plugin":"anchors","key":"id"}`, `Index.parse` should round-trip it, and `PartialIndex#resolve` should throw `PluginDataIndex requires pluginKey` / `DataIndex cannot be combined with pluginName` +- [x] 1.2 Rename `TuneIndex` → `PluginDataIndex` (`IndexKind.PluginData`; TypeScript members `pluginName`/`pluginKey`, serialized keys `plugin`/`key`), `Index.tune` → `Index.pluginData`, and update `PartialIndex` fields and error messages +- [x] 1.3 Rename `BlockTuneName`/`createBlockTuneName`/`BlockTuneSerialized` → `PluginDataName`/`createPluginDataName`/`PluginDataSerialized` (`src/BlockTune.ts` → `src/PluginData.ts`) and `TuneModifiedEvent` → `PluginDataModifiedEvent`. Update `EventMap` (`BlockTuneEvents` → `PluginDataEvents`) and the exports in `index.ts` +- [x] 1.4 Rename the `tunes` key to `plugins` in `BlockNodeSerialized`/`BlockNodeInit`/`BlockData`, and add a serialization test asserting no `tunes` key is produced +- [x] 1.5 Update `sdk` re-exports of the renamed types and fix every compile error across the workspace (`yarn build` clean) +- [x] 1.6 Update the jest mocks and fixtures the rename touches: `packages/model/src/entities/BlockNode/__mocks__/index.ts` (`updateTuneData`, plus a `plugins` getter if tests read it), `packages/model/src/entities/BlockTune/__mocks__`, the `jest.mock('../BlockTune')` path in `BlockNode.spec.ts`, and the `types/BlockTuneConstructorParameters.ts` / entities barrel names +- [x] 1.7 Update `packages/model/src/EditorJSModel.spec.ts`'s hard-coded prototype-method allowlist, which contains `'updateTuneData'` as a string and so breaks with no compile error ## 2. Model: plugin data node and create-on-first-write -- [ ] 2.1 Write failing `BlockNode.spec.ts` cases: updating an absent plugin name should create the entry and emit `PluginDataModifiedEvent` with `previous` `undefined`; updating one key should emit exactly one event; setting a key to `undefined` should remove it and drop an empty entry from serialization -- [ ] 2.2 Rename the `BlockTune` entity to `PluginDataNode` (`src/entities/PluginDataNode`), implement key deletion on `undefined`, and make `serialized` omit deleted keys -- [ ] 2.3 Write failing `BlockNode.spec.ts` cases for the store's key handling: a plugin named `__proto__` or `toString` should round-trip as ordinary data, and an empty plugin name should throw -- [ ] 2.4 Rename `BlockNode.#tunes`/`tunes`/`updateTuneData` → `#plugins`/`plugins`/`updatePluginData`: hold entries in a null-prototype record, reject an empty plugin name, create a missing node **empty** and attach its bubbling listener before the first `update` (the node dispatches synchronously, and without the listener the whole feature silently no-ops), and have `serialized` omit empty entries while still emitting an empty `plugins` map -- [ ] 2.5 Rename `EditorDocument.updateTuneData` and `EditorJSModel.updateTuneData` → `updatePluginData`, keeping `@WithContext`, and add a facade test that the event carries the acting `userId` -- [ ] 2.6 Write a failing test, then add a `PluginDataIndex` branch to `EditorDocument.modifyData` that sets the key to `data.value` -- [ ] 2.7 Write a failing test at the `BlockNode`/`EditorDocument` level, then replace the hardcoded `'user'` in the bubbled plugin-data event with `getContext()`, as the value-node path does -- [ ] 2.8 Add a test that a `plugins` entry for an unregistered plugin survives an initialize → serialize round trip +- [x] 2.1 Write failing `BlockNode.spec.ts` cases: updating an absent plugin name should create the entry and emit `PluginDataModifiedEvent` with `previous` `undefined`; updating one key should emit exactly one event; setting a key to `undefined` should remove it and drop an empty entry from serialization +- [x] 2.2 Rename the `BlockTune` entity to `PluginDataNode` (`src/entities/PluginDataNode`), implement key deletion on `undefined`, and make `serialized` omit deleted keys +- [x] 2.3 Write failing `BlockNode.spec.ts` cases for the store's key handling: a plugin named `__proto__` or `toString` should round-trip as ordinary data, and an empty plugin name should throw +- [x] 2.4 Rename `BlockNode.#tunes`/`tunes`/`updateTuneData` → `#plugins`/`plugins`/`updatePluginData`: hold entries in a null-prototype record, reject an empty plugin name, create a missing node **empty** and attach its bubbling listener before the first `update` (the node dispatches synchronously, and without the listener the whole feature silently no-ops), and have `serialized` omit empty entries while still emitting an empty `plugins` map +- [x] 2.5 Rename `EditorDocument.updateTuneData` and `EditorJSModel.updateTuneData` → `updatePluginData`, keeping `@WithContext`, and add a facade test that the event carries the acting `userId` +- [x] 2.6 Write a failing test, then add a `PluginDataIndex` branch to `EditorDocument.modifyData` that sets the key to `data.value` +- [x] 2.7 Write a failing test at the `BlockNode`/`EditorDocument` level, then replace the hardcoded `'user'` in the bubbled plugin-data event with `getContext()`, as the value-node path does +- [x] 2.8 Add a test that a `plugins` entry for an unregistered plugin survives an initialize → serialize round trip ## 3. Core and SDK API: plugin data access -- [ ] 3.1 Add `getPluginData`/`updatePluginData` to the SDK `BlocksAPI` interface, and a `plugins` map to the `insert` params -- [ ] 3.2 Write failing `BlocksAPI.spec.ts` / `BlockManager` tests covering read, write, absent-entry `undefined`, unknown-block error, and insert with a `plugins` map -- [ ] 3.3 Implement them in `core/src/api/BlocksAPI.ts` and `BlockManager` (defaulting `userId` to the config user), and replace the commented-out `tunes` stubs in `BlockManager` -- [ ] 3.4 Write a failing test, then fix `BlocksManager.insert`, which spreads `{ ...data, id, name }` while `BlockNode` reads a nested `data` key — so `insert({ data })` silently creates an empty block today. Nest `data` and pass `plugins` as its own key -- [ ] 3.5 Write failing tests, then make `convert` carry plugin data over and `split` create the new block without it (`move` should already pass — add a test to confirm) -- [ ] 3.6 Write a failing `UndoRedoManager` integration test (first write → undo removes the entry → redo restores it; an update undoes to the previous value), then fix whatever remains after the `modifyData` branch in 2.6 +- [x] 3.1 Add `getPluginData`/`updatePluginData` to the SDK `BlocksAPI` interface, and a `plugins` map to the `insert` params +- [x] 3.2 Write failing `BlocksAPI.spec.ts` / `BlockManager` tests covering read, write, absent-entry `undefined`, unknown-block error, and insert with a `plugins` map +- [x] 3.3 Implement them in `core/src/api/BlocksAPI.ts` and `BlockManager` (defaulting `userId` to the config user), and replace the commented-out `tunes` stubs in `BlockManager` +- [x] 3.4 Write a failing test, then fix `BlocksManager.insert`, which spreads `{ ...data, id, name }` while `BlockNode` reads a nested `data` key — so `insert({ data })` silently creates an empty block today. Nest `data` and pass `plugins` as its own key +- [x] 3.5 Write failing tests, then make `convert` carry plugin data over and `split` create the new block without it (`move` should already pass — add a test to confirm) +- [x] 3.6 Write a failing `UndoRedoManager` integration test (first write → undo removes the entry → redo restores it; an update undoes to the previous value), then fix whatever remains after the `modifyData` branch in 2.6 ## 4. Collaboration: plugin data operations -- [ ] 4.1 Write a failing `CollaborationManager.spec.ts` case: a `PluginDataModifiedEvent` should produce a `Modify` operation with `payload`/`prevPayload` -- [ ] 4.2 Add the `PluginDataModifiedEvent` case to `CollaborationManager.#handleEvent` -- [ ] 4.3 Write failing `OperationsTransformer.spec.ts` cases: shift against block insert, `Neutral` against removal of its block, a plugin-data operation used as the against-operation, and no transform between two plugin-data modifies. Add a regression assertion (not a failing test) that `Operation.inverse()` already swaps `payload`/`prevPayload` for any `Modify` -- [ ] 4.4 Add a `PluginDataIndex` branch to `OperationsTransformer.#applyTransformation`, which dispatches on the **against** operation's index kind and today throws `'Unsupported index type'`. Cover the three call sites in tests: `OTClient`'s pending-operation reduce, `UndoRedoManager.transformStacks`, and `ot-server`'s conflicting-operation reduce -- [ ] 4.5 Widen `ModifyOperationData`'s payload type from `Record` so a boolean or string payload compiles, with a test constructing a scalar plugin-data operation -- [ ] 4.6 Add an integration test with two clients where a plugin data change on one is reflected in the other's model -- [ ] 4.7 Update `packages/ot-server`: its `DocumentManager.spec.ts` uses `tunes: {}` both as `initializeDocument` input and inside `expect.objectContaining` assertions. The server applies operations against its own model, so it must be verified end to end with a plugin-data operation +- [x] 4.1 Write a failing `CollaborationManager.spec.ts` case: a `PluginDataModifiedEvent` should produce a `Modify` operation with `payload`/`prevPayload` +- [x] 4.2 Add the `PluginDataModifiedEvent` case to `CollaborationManager.#handleEvent` +- [x] 4.3 Write failing `OperationsTransformer.spec.ts` cases: shift against block insert, `Neutral` against removal of its block, a plugin-data operation used as the against-operation, and no transform between two plugin-data modifies. Add a regression assertion (not a failing test) that `Operation.inverse()` already swaps `payload`/`prevPayload` for any `Modify` +- [x] 4.4 Add a `PluginDataIndex` branch to `OperationsTransformer.#applyTransformation`, which dispatches on the **against** operation's index kind and today throws `'Unsupported index type'`. Cover the three call sites in tests: `OTClient`'s pending-operation reduce, `UndoRedoManager.transformStacks`, and `ot-server`'s conflicting-operation reduce +- [x] 4.5 Widen `ModifyOperationData`'s payload type from `Record` so a boolean or string payload compiles, with a test constructing a scalar plugin-data operation +- [x] 4.6 Add an integration test with two clients where a plugin data change on one is reflected in the other's model +- [x] 4.7 Update `packages/ot-server`: its `DocumentManager.spec.ts` uses `tunes: {}` both as `initializeDocument` input and inside `expect.objectContaining` assertions. The server applies operations against its own model, so it must be verified end to end with a plugin-data operation ## 5. Typing and v2 conversion -- [ ] 5.1 Write a failing `pluginTypeMaps.spec.ts` case: a `EditorjsPluginDataMap` augmentation should type `getPluginData`/`updatePluginData` for that id, an undeclared id should fall back to `Record`, and a wrong data shape should be a compile error -- [ ] 5.2 Declare `EditorjsPluginDataMap` in `sdk/src/index.ts`, add its keys to `PluginId`, and type the two `BlocksAPI` methods through a non-distributive `PluginDataFor` helper -- [ ] 5.3 Write failing `composeDataFromVersion2.spec.ts` cases: object tune data should map to `plugins` under the same key, a primitive or array should land under `value`, a block with no or empty `tunes` should produce no `plugins` key, and existing data conversion should be unaffected (the converter maps no block id today — that is a separate fix) -- [ ] 5.4 Implement the `tunes` → `plugins` mapping in `core/src/utils/composeDataFromVersion2.ts` -- [ ] 5.5 Add an integration test loading a v2 `config.data` document with tune data and asserting the plugin data is readable through `api.blocks.getPluginData` +- [x] 5.1 Write a failing `pluginTypeMaps.spec.ts` case: a `EditorjsPluginDataMap` augmentation should type `getPluginData`/`updatePluginData` for that id, an undeclared id should fall back to `Record`, and a wrong data shape should be a compile error +- [x] 5.2 Declare `EditorjsPluginDataMap` in `sdk/src/index.ts`, add its keys to `PluginId`, and type the two `BlocksAPI` methods through a non-distributive `PluginDataFor` helper +- [x] 5.3 Write failing `composeDataFromVersion2.spec.ts` cases: object tune data should map to `plugins` under the same key, a primitive or array should land under `value`, a block with no or empty `tunes` should produce no `plugins` key, and existing data conversion should be unaffected (the converter maps no block id today — that is a separate fix) +- [x] 5.4 Implement the `tunes` → `plugins` mapping in `core/src/utils/composeDataFromVersion2.ts` +- [x] 5.5 Add an integration test loading a v2 `config.data` document with tune data and asserting the plugin data is readable through `api.blocks.getPluginData` ## 6. Docs and wrap-up -- [ ] 6.1 Update `docs/model.md`, `docs/index-serialization.md` and `docs/events.md` for `PluginDataNode`, `PluginDataIndex` and `PluginDataModifiedEvent`, plus `docs/plugins.md` (the SDK declares **three** augmentable interfaces now) and `.github/agents/docs-updater.agent.md` (its `BlockTune` → `docs/model.md` mapping) -- [ ] 6.2 Update the diagrams `model-tree-structure.mmd`, `events-catalog.mmd` and `architecture-overview.mmd` -- [ ] 6.3 Run `yarn lint`, `yarn test`, and the mutation tests for the touched packages that have them. Fix any regressions +- [x] 6.1 Update `docs/model.md`, `docs/index-serialization.md` and `docs/events.md` for `PluginDataNode`, `PluginDataIndex` and `PluginDataModifiedEvent`, plus `docs/plugins.md` (the SDK declares **three** augmentable interfaces now) and `.github/agents/docs-updater.agent.md` (its `BlockTune` → `docs/model.md` mapping) +- [x] 6.2 Update the diagrams `model-tree-structure.mmd`, `events-catalog.mmd` and `architecture-overview.mmd` +- [x] 6.3 Run `yarn lint`, `yarn test`, and the mutation tests for the touched packages that have them. Fix any regressions - [ ] 6.4 Run `openspec validate plugin-block-data --type change` and confirm it passes. After archiving, fill the `## Purpose` of the new `plugin-block-data` spec from the proposal's intended-Purpose text ## 7. Review follow-ups -- [ ] 7.1 Write a failing `PluginDataNode.spec.ts` case for each no-op write — the value a key already holds, and the removal of a key that is not there — then make `update` return before dispatching in both. Add a `UndoRedoManager.integration.spec.ts` case asserting a repeated write leaves the undo stack alone -- [ ] 7.2 Add an `Operation.spec.ts` case round-tripping a key removal on a `PluginDataIndex` through `serialize()` and `JSON.parse`: the payload is `undefined`, which `JSON.stringify` drops, so what proves the removal survives is reading it back +- [x] 7.1 Write a failing `PluginDataNode.spec.ts` case for each no-op write — the value a key already holds, and the removal of a key that is not there — then make `update` return before dispatching in both. Add a `UndoRedoManager.integration.spec.ts` case asserting a repeated write leaves the undo stack alone +- [x] 7.2 Add an `Operation.spec.ts` case round-tripping a key removal on a `PluginDataIndex` through `serialize()` and `JSON.parse`: the payload is `undefined`, which `JSON.stringify` drops, so what proves the removal survives is reading it back ## 8. Review follow-ups (second round) -- [ ] 8.1 Write a failing `OperationsTransformer.spec.ts` case for two clients writing the same plugin data key concurrently, then give `#transformAgainstPluginDataOperation` the revision rule `#transformAgainstDataOperation` already uses: `Neutral` when the against-operation is the later of the two, an unsent one counting as later than any revision, and only for the same block, plugin and key. Without it the client holding an unsent write applies the remote value and never re-applies its own, so it ends on a value the server does not hold -- [ ] 8.2 Reword the `sdk` delta's scenarios, which still say "both" and "either" of what is now three augmentable maps +- [x] 8.1 Write a failing `OperationsTransformer.spec.ts` case for two clients writing the same plugin data key concurrently, then give `#transformAgainstPluginDataOperation` the revision rule `#transformAgainstDataOperation` already uses: `Neutral` when the against-operation is the later of the two, an unsent one counting as later than any revision, and only for the same block, plugin and key. Without it the client holding an unsent write applies the remote value and never re-applies its own, so it ends on a value the server does not hold +- [x] 8.2 Reword the `sdk` delta's scenarios, which still say "both" and "either" of what is now three augmentable maps ## 9. Review follow-ups (third round) -- [ ] 9.1 Write failing `PluginDataNode.spec.ts` cases for a `__proto__` key arriving from a loaded document and for a first write to a key named `toString`, then copy the node's record onto a null prototype as the block's own plugin store already is. The outer store's comment explains the hazard; the inner record never got the same treatment, so the key was dropped on load and the inherited function went onto the undo stack as a previous value -- [ ] 9.2 Write a failing case, then return a detached copy from `PluginDataNode.serialized`. Handing out the live record makes read-modify-write silent loss: `getPluginData` → edit → `updatePluginData` has already changed the Model, so the no-op guard finds nothing to announce and the edit reaches neither collaborators nor the undo stack -- [ ] 9.3 Write a failing case, then have `BlocksManager.getPluginData` check for an own entry. The serialized map is an ordinary object, so a plugin named `toString` or `constructor` read back as the inherited function instead of `undefined` +- [x] 9.1 Write failing `PluginDataNode.spec.ts` cases for a `__proto__` key arriving from a loaded document and for a first write to a key named `toString`, then copy the node's record onto a null prototype as the block's own plugin store already is. The outer store's comment explains the hazard; the inner record never got the same treatment, so the key was dropped on load and the inherited function went onto the undo stack as a previous value +- [x] 9.2 Write a failing case, then return a detached copy from `PluginDataNode.serialized`. Handing out the live record makes read-modify-write silent loss: `getPluginData` → edit → `updatePluginData` has already changed the Model, so the no-op guard finds nothing to announce and the edit reaches neither collaborators nor the undo stack +- [x] 9.3 Write a failing case, then have `BlocksManager.getPluginData` check for an own entry. The serialized map is an ordinary object, so a plugin named `toString` or `constructor` read back as the inherited function instead of `undefined` + +## 10. Review follow-ups (fourth round) + +- [x] 10.1 Write failing `PluginDataNode.spec.ts` cases for a value the caller still holds — the object itself, its nested values, and the payload of the change already recorded — then copy incoming values with `structuredClone` on the way in, for the event payload, and for the data a node is constructed with. Sharing the object is the read-modify-write hazard of 9.2 from the other end: editing it moves the Model with no write at all, and the write-back that should have announced it finds the value unchanged and emits nothing, so the two documents diverge in silence +- [x] 10.2 Write a failing case for an equal-but-distinct object being written again, then compare values structurally in the no-op guard. Once stored values are copies, identity could only ever match a primitive, so every object write would emit and the rule from 7.1 would stop holding. `deepEqual` moves out of `isSameInlineData.ts` into `utils/deepEqual.ts` for the two to share, and gains a guard there: a value whose state is not in its own enumerable properties — a `Date`, a `Map` — would otherwise compare equal to any other of its kind, so switching off identity would have started swallowing writes that changed one +- [x] 10.3 Write failing `OperationsTransformer.spec.ts` and `UndoRedoManager.spec.ts` cases for an undo entry whose key a collaborator overwrote, then let an operation with no revision of its own lose to whatever it is transformed against. `againstRevision > operation.rev!` is `false` whenever the left side is a stack entry, because those are built from model events and never numbered — so the entry survived, and undoing a local first write after a remote overwrite deleted the collaborator's value. `#transformAgainstDataOperation` has the same hole for `data` values on `main`; it belongs with the other pre-existing OT defects on the board rather than in this PR + +## 11. Review follow-ups (fifth round) + +- [x] 11.1 Write a failing `OTClient.spec.ts` case for a collaborator's write arriving while the only local write is in flight, then keep an operation in `#pendingOperations` until its acknowledgement instead of removing it when it is sent. `#sendNextOperation` shifted the operation off the queue to send it, so for the whole round trip it was in neither the pending list nor anywhere else `#onMessage` looks: the remote write was transformed against nothing, applied over the local one, and never corrected, because a client's own operations are not echoed back to it. The convergence rule from 8.1 covered only the window before an operation is sent, which is a microtask, while this one is a full round trip -- so it was the likelier half and the one a simultaneous pair of `updatePluginData` calls actually lands in. `openspec/specs/collaboration-manager/spec.md` already required this ("transformed against the pending local operations ... while local operations are pending/unacknowledged"); the code did not conform, and this change's own wording said "unsent" where it meant "unacknowledged" +- [x] 11.2 Claim `#awaitingAcknowledgement` before the first `await` in `#sendNextOperation`. Reading the head operation rather than shifting it means two concurrent `send` calls would otherwise both read the same one and send it twice, where the old code sent each once in an arbitrary order diff --git a/openspec/changes/remove-block-tunes/tasks.md b/openspec/changes/remove-block-tunes/tasks.md index b79330e8..91b43ceb 100644 --- a/openspec/changes/remove-block-tunes/tasks.md +++ b/openspec/changes/remove-block-tunes/tasks.md @@ -1,26 +1,26 @@ ## 1. Guard the removal with tests -- [ ] 1.1 Add type-level assertions (`@ts-expect-error`) that these no longer exist: `ToolType.Tune`, the `BlockTune*` entity exports, `BlockTuneFacade`, `BlockToolOptionKey.Tunes`, `BlockToolOptions['tunes']`, `UserToolOptions.EnabledBlockTunes` and `BlockToolFacade#enabledBlockTunes`. Enumerate tool-kind names only — the model-layer `BlockTuneName`/`BlockTuneSerialized` re-exports belong to `plugin-block-data` -- [ ] 1.2 Write a failing `ToolsCollection` spec case: the collection should expose only `blockTools`/`inlineTools`. Also write a `Core`/`ToolsManager` case: a plugin that contributes block settings items should be instantiated as a plugin and absent from the tool collections +- [x] 1.1 Add type-level assertions (`@ts-expect-error`) that these no longer exist: `ToolType.Tune`, the `BlockTune*` entity exports, `BlockTuneFacade`, `BlockToolOptionKey.Tunes`, `BlockToolOptions['tunes']`, `UserToolOptions.EnabledBlockTunes` and `BlockToolFacade#enabledBlockTunes`. Enumerate tool-kind names only — the model-layer `BlockTuneName`/`BlockTuneSerialized` re-exports belong to `plugin-block-data` +- [x] 1.2 Write a failing `ToolsCollection` spec case: the collection should expose only `blockTools`/`inlineTools`. Also write a `Core`/`ToolsManager` case: a plugin that contributes block settings items should be instantiated as a plugin and absent from the tool collections ## 2. Remove from SDK -- [ ] 2.1 Delete `sdk/src/entities/BlockTune.ts` and `sdk/src/tools/facades/BlockTuneFacade.ts` (with its spec), and remove their exports -- [ ] 2.2 Remove `ToolType.Tune`, `BaseToolFacade#isTune()` and `ToolsCollection#blockTunes` (its only consumer) -- [ ] 2.3 Remove `BlockTuneConstructor` from `ToolConstructable`, `BlockTuneFacade` from `ToolFacadeClass` (and its export), `ToolTypeToOptions[ToolType.Tune]`, the `ToolStaticOptions` member, the `BlockTuneOptions` re-exports in `BaseTool.ts`/`BaseToolFacade.ts`, and the `./BlockTune.js` line in the entities barrel -- [ ] 2.4 Remove `BlockToolOptionKey.Tunes` and its `BlockToolOptions` member, `UserToolOptions.EnabledBlockTunes`, `BlockToolFacade#enabledBlockTunes` and `BlockToolFacade#tunes` +- [x] 2.1 Delete `sdk/src/entities/BlockTune.ts` and `sdk/src/tools/facades/BlockTuneFacade.ts` (it had no spec), and remove their exports +- [x] 2.2 Remove `ToolType.Tune`, `BaseToolFacade#isTune()` and `ToolsCollection#blockTunes` (its only consumer) +- [x] 2.3 Remove `BlockTuneConstructor` from `ToolConstructable`, `BlockTuneFacade` from `ToolFacadeClass` (and its export), `ToolTypeToOptions[ToolType.Tune]`, the `ToolStaticOptions` member, the `BlockTuneOptions` re-exports in `BaseTool.ts`/`BaseToolFacade.ts`, and the `./BlockTune.js` line in the entities barrel +- [x] 2.4 Remove `BlockToolOptionKey.Tunes` and its `BlockToolOptions` member, `UserToolOptions.EnabledBlockTunes`, `BlockToolFacade#enabledBlockTunes` and `BlockToolFacade#tunes` ## 3. Remove from Core and dependents -- [ ] 3.1 Remove the Tune branch from `Core#use`, the `getAll(ToolType.Tune)` step in tool preparation, the Tune case in `ToolsFactory`, and `ToolsManager#blockTunes` -- [ ] 3.2 Remove `#processBlockTune` and the `BlockTuneFacade` import from `packages/plugins/shortcuts-plugin/src/index.ts` -- [ ] 3.3 Grep the workspace for the removed tool-kind symbols (not the bare word `tunes`, which the model layer still uses until `plugin-block-data` lands), fix any leftovers, and confirm `yarn build` is clean +- [x] 3.1 Remove the Tune branch from `Core#use`, the `getAll(ToolType.Tune)` step in tool preparation, the Tune case in `ToolsFactory`, and `ToolsManager#blockTunes` +- [x] 3.2 Remove `#processBlockTune` and the `BlockTuneFacade` import from `packages/plugins/shortcuts-plugin/src/index.ts` +- [x] 3.3 Grep the workspace for the removed tool-kind symbols (not the bare word `tunes`, which the model layer still uses until `plugin-block-data` lands), fix any leftovers, and confirm `yarn build` is clean ## 4. Docs and wrap-up -- [ ] 4.1 Update `docs/plugins.md` (the "Block Tune" table row and the SDK re-export list on line 5), `docs/README.md` (drop the `BlockTune` contract mention) and `docs/diagrams/plugin-lifecycle-flow.mmd` (drop the `ToolType.Tune` binding and `blockTunes`) -- [ ] 4.2 Hand-edit the **Purpose** preamble of `openspec/specs/sdk/spec.md` to drop "block tunes" — the mechanical archive fold only rewrites `### Requirement:` blocks -- [ ] 4.3 Change the `shortcuts-plugin` spec's reserved-work note from "block tunes" to "block settings items" -- [ ] 4.4 Run `yarn lint` and `yarn test`. Fix any regressions +- [x] 4.1 Update `docs/plugins.md` (the "Block Tune" table row and the SDK re-export list on line 5), `docs/README.md` (drop the `BlockTune` contract mention) and `docs/diagrams/plugin-lifecycle-flow.mmd` (drop the `ToolType.Tune` binding and `blockTunes`) +- [x] 4.2 Hand-edit the **Purpose** preamble of `openspec/specs/sdk/spec.md` to drop "block tunes" — the mechanical archive fold only rewrites `### Requirement:` blocks +- [x] 4.3 Change the `shortcuts-plugin` spec's reserved-work note from "block tunes" to "block settings items" +- [x] 4.4 Run `yarn lint` and `yarn test`. Fix any regressions - [ ] 4.5 Comment on PR #157 linking `plugin-block-data`, `block-settings-ui` and `remove-block-tunes` as its replacement -- [ ] 4.6 Run `openspec validate remove-block-tunes --type change` and confirm it passes +- [x] 4.6 Run `openspec validate remove-block-tunes --type change` and confirm it passes diff --git a/package.json b/package.json index 95a976bb..578e6e63 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "packages/plugins/*" ], "scripts": { - "build": "yarn workspaces foreach -At run build", + "build": "yarn workspaces foreach -A --topological-dev run build", "test": "yarn workspaces foreach -A run test", "lint": "yarn workspaces foreach -A run lint", "lint:fix": "yarn workspaces foreach -A run lint --fix", diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index a1c2d2bc..9039bde6 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ -import { createDataKey, EventType, Index } from '@editorjs/sdk'; +import { createDataKey, createPluginDataName, EventType, Index } from '@editorjs/sdk'; import { EditorJSModel } from '@editorjs/model'; import { CoreEventType, type CoreConfig } from '@editorjs/sdk'; import { beforeAll, jest } from '@jest/globals'; @@ -67,7 +67,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -109,7 +109,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -147,7 +147,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -221,7 +221,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -313,7 +313,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -362,7 +362,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -379,6 +379,67 @@ describe('CollaborationManager', () => { }); }); + describe('plugin data operations', () => { + const pluginName = createPluginDataName('anchors'); + + /** + * Creates a model with one paragraph block + */ + function createModel(): InstanceType { + const model = new EditorJSModel(userId, { identifier: documentId }); + + model.initializeDocument({ + blocks: [{ + name: 'paragraph', + data: {}, + }], + }); + + return model; + } + + it('should turn a plugin data change into a Modify operation instead of logging an unknown event', () => { + const model = createModel(); + const { manager } = createManager(config as Required, model); + const transformSpy = jest.spyOn(UndoRedoManager.prototype, 'transformStacks'); + + /** + * A change by another user takes the remote path, where the operation built from the event + * is handed to the undo/redo stacks — which is observable without a connected client. + */ + model.updatePluginData('another-user', 0, pluginName, { visible: false }); + + expect(transformSpy).toHaveBeenCalledTimes(1); + + const operation = transformSpy.mock.calls[0][0]; + + expect(operation.type).toBe(OperationType.Modify); + expect(operation.data).toEqual({ payload: false, + prevPayload: undefined }); + expect(operation.index.serialize()).toContain('"k":"plugin"'); + + transformSpy.mockRestore(); + manager.destroy(); + }); + + it('should apply a remote plugin data operation to the model', () => { + const model = createModel(); + const { manager } = createManager(config as Required, model); + + manager.applyOperation(new Operation( + OperationType.Modify, + Index.pluginData(0, pluginName, 'visible'), + { payload: false, + prevPayload: undefined }, + 'remote-user' + )); + + expect(model.serialized.blocks[0].plugins?.[pluginName]).toEqual({ visible: false }); + + manager.destroy(); + }); + }); + describe('undo logic', () => { beforeAll(() => { jest.useFakeTimers(); @@ -414,7 +475,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -457,7 +518,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -501,7 +562,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -546,7 +607,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -624,7 +685,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -675,7 +736,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -730,7 +791,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -757,7 +818,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - tunes: {}, + plugins: {}, }; model.initializeDocument({ @@ -792,7 +853,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - tunes: {}, + plugins: {}, }; model.initializeDocument({ @@ -830,7 +891,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - tunes: {}, + plugins: {}, }; model.initializeDocument({ @@ -871,7 +932,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - tunes: {}, + plugins: {}, }; model.initializeDocument({ @@ -941,7 +1002,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -993,7 +1054,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -1030,7 +1091,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -1124,7 +1185,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -1165,7 +1226,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -1233,7 +1294,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -1296,7 +1357,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -1349,7 +1410,7 @@ describe('CollaborationManager', () => { identifier: documentId, blocks: [expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index 75d8b82d..9a32b205 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -2,6 +2,7 @@ import { BlockAddedEvent, type BlockNodeSerialized, BlockRemovedEvent, type ModelEvents, + PluginDataModifiedEvent, TextAddedEvent, TextFormattedEvent, TextRemovedEvent, TextUnformattedEvent @@ -265,6 +266,12 @@ export class CollaborationManager implements EditorjsPlugin { payload: [e.detail.data], }, e.detail.userId); break; + case (e instanceof PluginDataModifiedEvent): + operation = new Operation(OperationType.Modify, e.detail.index, { + payload: e.detail.data.value, + prevPayload: e.detail.data.previous, + }, e.detail.userId); + break; // Stryker disable next-line ConditionalExpression default: // Stryker disable next-line StringLiteral diff --git a/packages/collaboration-manager/src/Operation.spec.ts b/packages/collaboration-manager/src/Operation.spec.ts index 87bf21c1..c0633632 100644 --- a/packages/collaboration-manager/src/Operation.spec.ts +++ b/packages/collaboration-manager/src/Operation.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import type { BlockIndex, BlockNodeSerialized, DataKey, DocumentId, TextIndex } from '@editorjs/sdk'; -import { Index } from '@editorjs/sdk'; +import { createPluginDataName, Index, PluginDataIndex } from '@editorjs/sdk'; import { describe } from '@jest/globals'; import { type InsertOrDeleteOperationData, type ModifyOperationData, Operation, OperationType } from './Operation.js'; @@ -324,4 +324,45 @@ describe('Operation', () => { expect(() => op.inverse()).toThrow('Unsupported operation type'); }); }); + describe('.serialize()', () => { + /** + * An operation reaches the OT server as JSON, and a key removal is a Modify whose payload + * is `undefined` -- which `JSON.stringify` drops entirely. The removal survives only + * because reading a key that is not there gives `undefined` back, which is worth asserting + * since nothing in the types says so. + */ + it('should keep a plugin data removal through JSON serialization', () => { + const index = Index.pluginData(2, createPluginDataName('anchors'), 'visible'); + const op = new Operation(OperationType.Modify, index, { + payload: undefined, + prevPayload: 'intro', + }, 'user-1'); + + op.rev = 7; + + const received = JSON.parse(JSON.stringify(op.serialize())) as ReturnType; + + expect((received.data as ModifyOperationData).payload).toBeUndefined(); + expect('payload' in received.data).toBe(false); + expect((received.data as ModifyOperationData).prevPayload).toBe('intro'); + }); + + it('should keep the plugin data index itself through JSON serialization', () => { + const index = Index.pluginData(2, createPluginDataName('anchors'), 'visible'); + const op = new Operation(OperationType.Modify, index, { + payload: undefined, + prevPayload: 'intro', + }, 'user-1'); + + op.rev = 7; + + const received = JSON.parse(JSON.stringify(op.serialize())) as ReturnType; + const parsed = Index.parse(received.index) as PluginDataIndex; + + expect(parsed).toBeInstanceOf(PluginDataIndex); + expect(parsed.blockIndex).toBe(2); + expect(parsed.pluginName).toBe('anchors'); + expect(parsed.pluginKey).toBe('visible'); + }); + }); }); diff --git a/packages/collaboration-manager/src/Operation.ts b/packages/collaboration-manager/src/Operation.ts index dbda5c8f..c79ff0b1 100644 --- a/packages/collaboration-manager/src/Operation.ts +++ b/packages/collaboration-manager/src/Operation.ts @@ -28,8 +28,13 @@ export interface InsertOrDeleteOperationData { payload: ArrayLike; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface ModifyOperationData = Record> { +/** + * Data of a Modify operation. + * + * The payload is any JSON value, not just an object: a plugin's per-block data holds scalars, + * so `payload: false` has to be representable. + */ +export interface ModifyOperationData { /** * Operation payload */ @@ -186,7 +191,13 @@ export class Operation { const textIndex = this.index as TextIndex; if (this.type === OperationType.Insert) { - return [textIndex.textRange![0], Math.max(textIndex.textRange![1], textIndex.textRange![0] + this.data.payload!.length)]; + /** + * Only an Insert operation reaches this branch, and its payload is always the inserted + * text or block list — unlike a Modify payload, which may be any JSON value. + */ + const { payload } = this.data as InsertOrDeleteOperationData; + + return [textIndex.textRange![0], Math.max(textIndex.textRange![1], textIndex.textRange![0] + payload.length)]; } return textIndex.textRange!; diff --git a/packages/collaboration-manager/src/OperationsTransformer.spec.ts b/packages/collaboration-manager/src/OperationsTransformer.spec.ts index c4aa97fb..adde8df4 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.spec.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.spec.ts @@ -1,5 +1,5 @@ -import type { BlockIndex, DocumentId, TextIndex } from '@editorjs/sdk'; -import { createDataKey, Index } from '@editorjs/sdk'; +import type { BlockIndex, DocumentId, PluginDataIndex, TextIndex } from '@editorjs/sdk'; +import { createDataKey, createPluginDataName, Index } from '@editorjs/sdk'; import { Operation, OperationType } from './Operation.js'; import { OperationsTransformer } from './OperationsTransformer.js'; @@ -552,4 +552,222 @@ describe('OperationsTransformer', () => { }); }); }); + + describe('Plugin data operations transformation', () => { + const pluginIndex = (blockIndex: number, key = 'id'): Index => + Index.pluginData(blockIndex, createPluginDataName('anchors'), key, 'doc1' as DocumentId); + + const pluginOp = (blockIndex: number, key = 'id'): Operation => new Operation( + OperationType.Modify, + pluginIndex(blockIndex, key), + { payload: false, + prevPayload: true }, + 'user1', + 1 + ); + + it('should shift the block index when a block is inserted above', () => { + const operation = pluginOp(2); + const againstOp = new Operation( + OperationType.Insert, + Index.block(0, 'doc1' as DocumentId), + { payload: [] }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect((result.index as PluginDataIndex).blockIndex).toBe(3); + expect((result.index as PluginDataIndex).pluginName).toBe('anchors'); + }); + + it('should become Neutral when its own block is removed', () => { + const operation = pluginOp(2); + const againstOp = new Operation( + OperationType.Delete, + Index.block(2, 'doc1' as DocumentId), + { payload: [] }, + 'user2', + 1 + ); + + expect(transformer.transform(operation, againstOp).type).toBe(OperationType.Neutral); + }); + + /** + * The divergence this guards against: the client holding the unsent write applies the + * remote value, and its own operation is never echoed back to it, so nothing restores the + * value the server ends up holding. The same rule covers data values already + */ + it('should become Neutral against an unsent write to the same key', () => { + const incoming = pluginOp(2); + const unsentLocal = new Operation( + OperationType.Modify, + pluginIndex(2), + { payload: true, + prevPayload: false }, + 'user2' + ); + + expect(unsentLocal.rev).toBeUndefined(); + expect(transformer.transform(incoming, unsentLocal).type).toBe(OperationType.Neutral); + }); + + it('should be applied over a write the server took before it', () => { + const operation = pluginOp(2); + const againstOp = pluginOp(2); + + const result = transformer.transform(operation, againstOp); + + expect(result.serialize()).toEqual(operation.serialize()); + }); + + it('should not be transformed against an unsent write to another key of the same plugin', () => { + const incoming = pluginOp(2, 'visible'); + const unsentLocal = new Operation( + OperationType.Modify, + pluginIndex(2, 'id'), + { payload: true, + prevPayload: false }, + 'user2' + ); + + const result = transformer.transform(incoming, unsentLocal); + + /** + * Compared serialized, so this covers the index, the payloads, the user and the + * revision. Asserting the type alone says only that the operation was not neutralised, + * which is not what "not transformed" means + */ + expect(result.serialize()).toEqual(incoming.serialize()); + }); + + it('should not be transformed against an unsent write to another plugin', () => { + const incoming = pluginOp(2); + const unsentLocal = new Operation( + OperationType.Modify, + Index.pluginData(2, createPluginDataName('spoiler'), 'id', 'doc1' as DocumentId), + { payload: true, + prevPayload: false }, + 'user2' + ); + + const result = transformer.transform(incoming, unsentLocal); + + /** + * Compared serialized, so this covers the index, the payloads, the user and the + * revision. Asserting the type alone says only that the operation was not neutralised, + * which is not what "not transformed" means + */ + expect(result.serialize()).toEqual(incoming.serialize()); + }); + + it('should not be transformed against an unsent write to the same key of another block', () => { + const incoming = pluginOp(2); + const unsentLocal = new Operation( + OperationType.Modify, + pluginIndex(3), + { payload: true, + prevPayload: false }, + 'user2' + ); + + const result = transformer.transform(incoming, unsentLocal); + + /** + * Compared serialized, so this covers the index, the payloads, the user and the + * revision. Asserting the type alone says only that the operation was not neutralised, + * which is not what "not transformed" means + */ + expect(result.serialize()).toEqual(incoming.serialize()); + }); + + /** + * Only a stack entry reaches the transformer with no revision of its own, and the operation it is transformed + * against has already been applied -- so the entry describes a value the document has moved on from + */ + describe('when the operation itself has no revision', () => { + /** + * A stack entry, as `CollaborationManager` builds it from a model event + * @param key - plugin data key the entry writes to + */ + const stackEntry = (key = 'id'): Operation => new Operation( + OperationType.Modify, + pluginIndex(2, key), + { payload: false, + prevPayload: true }, + 'user1' + ); + + it('should become Neutral against a numbered write to the same key', () => { + const entry = stackEntry(); + + expect(entry.rev).toBeUndefined(); + expect(transformer.transform(entry, pluginOp(2)).type).toBe(OperationType.Neutral); + }); + + /** + * The remote operation is reconstructed from a model event too, so it carries no revision either -- which + * is the shape the undo stack actually sees + */ + it('should become Neutral against an unnumbered write to the same key', () => { + const remote = new Operation( + OperationType.Modify, + pluginIndex(2), + { payload: true, + prevPayload: false }, + 'user2' + ); + + expect(transformer.transform(stackEntry(), remote).type).toBe(OperationType.Neutral); + }); + + it('should survive a write to another key of the same plugin', () => { + const entry = stackEntry('visible'); + + const result = transformer.transform(entry, pluginOp(2, 'id')); + + expect(result.serialize()).toEqual(entry.serialize()); + }); + }); + + it('should transform a text operation against a plugin data operation without throwing', () => { + const operation = new Operation( + OperationType.Insert, + Index.text([{ blockIndex: 1, + dataKey: createDataKey('text'), + textRange: [0, 0], + documentId: 'doc1' as DocumentId }]), + { payload: 'abc' }, + 'user1', + 1 + ); + + const result = transformer.transform(operation, pluginOp(1)); + + expect(result.type).toBe(OperationType.Insert); + }); + + it('should transform a block operation against a plugin data operation without throwing', () => { + const operation = new Operation( + OperationType.Insert, + Index.block(0, 'doc1' as DocumentId), + { payload: [] }, + 'user1', + 1 + ); + + const result = transformer.transform(operation, pluginOp(0)); + + expect(result.type).toBe(OperationType.Insert); + }); + + it('should invert a plugin data operation by swapping the payloads', () => { + const inverted = pluginOp(2).inverse(); + + expect(inverted.data).toEqual({ payload: true, + prevPayload: false }); + }); + }); }); diff --git a/packages/collaboration-manager/src/OperationsTransformer.ts b/packages/collaboration-manager/src/OperationsTransformer.ts index 6930d6e3..bbc399b1 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.ts @@ -1,4 +1,5 @@ -import { BlockIndex, DataIndex, TextIndex } from '@editorjs/sdk'; +import { BlockIndex, DataIndex, PluginDataIndex, TextIndex } from '@editorjs/sdk'; +import type { InsertOrDeleteOperationData } from './Operation.js'; import { Operation, OperationType } from './Operation.js'; import { getRangesIntersectionType, RangeIntersectionType } from './utils/getRangesIntersectionType.js'; @@ -61,6 +62,9 @@ export class OperationsTransformer { case (againstIndex instanceof DataIndex): return this.#transformAgainstDataOperation(operation, againstOp); + case (againstIndex instanceof PluginDataIndex): + return this.#transformAgainstPluginDataOperation(operation, againstOp); + default: throw new Error('Unsupported index type'); } @@ -194,6 +198,63 @@ export class OperationsTransformer { } } + /** + * Method that transforms operation against a plugin data operation + * + * Cases, mirroring #transformAgainstDataOperation: a plugin data operation modifies one key of + * one plugin's per-block data, so it can only ever conflict with another write to that same + * key, and can never shift an operation's position. + * 1. Operation is not a plugin data operation + * - Return copy of the current operation + * 2. Operation describes a different block, plugin or key + * - Return copy of the current operation, the two values are independent + * 3. Operation describes the same value + * - Only the later write should survive, so compare revisions as data values do + * @param operation - Operation to be transformed + * @param againstOp - Operation against which the current operation should be transformed + * @returns new transformed operation + */ + #transformAgainstPluginDataOperation(operation: Operation, againstOp: Operation): Operation | Operation { + const index = operation.index; + + // Cover case 1 + if (!(index instanceof PluginDataIndex)) { + return Operation.from(operation); + } + + const againstIndex = againstOp.index as PluginDataIndex; + + // Cover case 2 + if ( + index.blockIndex !== againstIndex.blockIndex + || index.pluginName !== againstIndex.pluginName + || index.pluginKey !== againstIndex.pluginKey + ) { + return Operation.from(operation); + } + + /** + * An againstOp with no revision has not reached the OT server yet, so there is no telling + * when it will be applied there. Treating it as a future operation is what keeps a client + * that holds an unsent write from applying the remote value over it: its own operation is + * never echoed back to it, so nothing would restore the value the server ends up holding + */ + const againstRevision = againstOp.rev === undefined ? Infinity : againstOp.rev; + + /** + * Only an undo or redo stack entry reaches this with no revision, and it is transformed against an operation the + * model has already applied -- so it describes a value that is gone, and undoing it would delete the newer one + */ + const revision = operation.rev === undefined ? -Infinity : operation.rev; + + // Cover case 3 + if (againstRevision > revision) { + return new Operation(OperationType.Neutral, index, { payload: [] }, operation.userId, operation.rev); + } + + return Operation.from(operation); + } + /** * Method that transforms operation against data (value) operation * @@ -264,7 +325,7 @@ export class OperationsTransformer { #transformAgainstTextInsert(operation: Operation, againstOp: Operation): Operation | Operation { let newPayload = operation.data.payload as string; - const insertedLength = againstOp.data.payload!.length; + const insertedLength = (againstOp.data as InsertOrDeleteOperationData).payload.length; const index = operation.index as TextIndex; const againstIndex = againstOp.index as TextIndex; @@ -352,7 +413,7 @@ export class OperationsTransformer { */ #transformAgainstTextDelete(operation: Operation, againstOp: Operation): Operation | Operation { let newPayload = operation.data.payload as string; - const deletedAmount = againstOp.data.payload!.length; + const deletedAmount = (againstOp.data as InsertOrDeleteOperationData).payload.length; const textRange = operation.getEffectiveRange(); const againstTextRange = againstOp.getEffectiveRange(); diff --git a/packages/collaboration-manager/src/UndoRedoManager.spec.ts b/packages/collaboration-manager/src/UndoRedoManager.spec.ts index 276f0d18..42210cff 100644 --- a/packages/collaboration-manager/src/UndoRedoManager.spec.ts +++ b/packages/collaboration-manager/src/UndoRedoManager.spec.ts @@ -1,5 +1,5 @@ import type { BlockIndex, DocumentId, TextIndex } from '@editorjs/sdk'; -import { createDataKey, Index } from '@editorjs/sdk'; +import { createDataKey, createPluginDataName, Index } from '@editorjs/sdk'; import { describe } from '@jest/globals'; import { jest } from '@jest/globals'; import { Operation, OperationType } from './Operation.js'; @@ -223,5 +223,55 @@ describe('UndoRedoManager', () => { expect((undone?.index as TextIndex | undefined)?.textRange).toEqual([4, 7]); /* eslint-enable @typescript-eslint/no-magic-numbers */ }); + + /** + * Stack entries carry no revision, and are transformed against operations the model has applied: an entry a + * collaborator overwrote would write a value that is gone and delete theirs instead of undoing anything + */ + it('should drop a plugin data entry a collaborator has overwritten', () => { + const manager = new UndoRedoManager(); + const index = Index.pluginData(0, createPluginDataName('anchors'), 'color', 'doc' as DocumentId); + + manager.put(new Operation( + OperationType.Modify, + index, + { payload: 'red', + prevPayload: undefined }, + userId + )); + + manager.transformStacks(new Operation( + OperationType.Modify, + index, + { payload: 'blue', + prevPayload: 'red' }, + 'remote' + )); + + expect(manager.undo()).toBeUndefined(); + }); + + it('should keep a plugin data entry for a key the collaborator did not touch', () => { + const manager = new UndoRedoManager(); + const doc = 'doc' as DocumentId; + + manager.put(new Operation( + OperationType.Modify, + Index.pluginData(0, createPluginDataName('anchors'), 'color', doc), + { payload: 'red', + prevPayload: undefined }, + userId + )); + + manager.transformStacks(new Operation( + OperationType.Modify, + Index.pluginData(0, createPluginDataName('anchors'), 'label', doc), + { payload: 'blue', + prevPayload: undefined }, + 'remote' + )); + + expect(manager.undo()?.type).toBe(OperationType.Modify); + }); }); }); diff --git a/packages/collaboration-manager/src/client/OTClient.spec.ts b/packages/collaboration-manager/src/client/OTClient.spec.ts index 018ff399..68b1112f 100644 --- a/packages/collaboration-manager/src/client/OTClient.spec.ts +++ b/packages/collaboration-manager/src/client/OTClient.spec.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import type { DocumentId, EditorDocumentSerialized } from '@editorjs/sdk'; -import { createDataKey, Index } from '@editorjs/sdk'; +import { createDataKey, createPluginDataName, Index } from '@editorjs/sdk'; import { beforeEach, afterEach, jest, describe, it, expect } from '@jest/globals'; import { OTClient } from './OTClient.js'; -import { Operation, OperationType } from '../Operation.js'; +import { Operation, OperationType, type SerializedOperation } from '../Operation.js'; +import type { Message } from './Message.js'; import { MessageType } from './MessageType.js'; import { MockWebSocket } from '../../test/mocks/ws.js'; @@ -14,12 +15,29 @@ const documentId = 'document' as DocumentId; /** * Minimal stub document used when connecting the OTClient to a document. */ +/** + * Lets the asynchronous acknowledgement handler run to completion + */ +async function flushMicrotasks(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)); +} + const stubDocument: EditorDocumentSerialized = { identifier: documentId, blocks: [], properties: {}, }; +/** + * Operation messages the client has put on the wire, in order + * @param sendSpy - spy over the mock socket's send + */ +function sentOperations(sendSpy: jest.SpiedFunction): Message[] { + return sendSpy.mock.calls + .map(([data]) => JSON.parse(data as string) as Message) + .filter(message => message.type === MessageType.Operation); +} + describe('OTClient', () => { let OriginalWebSocket: typeof WebSocket; @@ -111,6 +129,65 @@ describe('OTClient', () => { expect(onRemoteOperation).not.toHaveBeenCalled(); }); + /** + * The window a single `updatePluginData` actually lands in: the operation is sent straight + * away, so the round trip to the server is where a collaborator's write collides with it + */ + it('should NOT call onRemoteOperation while the only local operation is in flight', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); + + await client.connectDocument(stubDocument); + + const index = Index.pluginData(0, createPluginDataName('anchors'), 'color', documentId); + + await client.send(new Operation(OperationType.Modify, index, { payload: 'red', + prevPayload: undefined }, userId)); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: new Operation(OperationType.Modify, index, { payload: 'blue', + prevPayload: undefined }, remoteUserId, 1).serialize(), + }); + + /** + * Applying it would leave this client on the collaborator's value while the server ends on + * ours, and its own operation is never echoed back to put that right + */ + expect(onRemoteOperation).not.toHaveBeenCalled(); + }); + + it('should call onRemoteOperation once the local operation has been acknowledged', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); + + await client.connectDocument(stubDocument); + + const index = Index.pluginData(0, createPluginDataName('anchors'), 'color', documentId); + + await client.send(new Operation(OperationType.Modify, index, { payload: 'red', + prevPayload: undefined }, userId)); + + // The server echoes the local operation back with a revision, which is its acknowledgement. + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: new Operation(OperationType.Modify, index, { payload: 'red', + prevPayload: undefined }, userId, 1).serialize(), + }); + await Promise.resolve(); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: new Operation(OperationType.Modify, index, { payload: 'blue', + prevPayload: 'red' }, remoteUserId, 2).serialize(), + }); + + /** + * The server ordered the collaborator's write after ours, so it is the one that stands + */ + expect(onRemoteOperation).toHaveBeenCalledTimes(1); + }); + it('should ignore messages with type other than Operation', async () => { const onRemoteOperation = jest.fn(); const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); @@ -214,6 +291,120 @@ describe('OTClient', () => { expect(opCalls).toHaveLength(1); sendSpy.mockRestore(); }); + + /** + * Both calls reach the send path before either awaits, so the guard has to be claimed + * before the first await or they both read the same head operation + */ + it('should send the head operation once when two sends race', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); + + const index = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + documentId }]); + + await Promise.all([ + client.send(new Operation(OperationType.Insert, index, { payload: 'a' }, userId)), + client.send(new Operation(OperationType.Insert, index, { payload: 'b' }, userId)), + ]); + + expect(sentOperations(sendSpy)).toHaveLength(1); + sendSpy.mockRestore(); + }); + + it('should send the queued operation once the first is acknowledged', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); + + const index = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + documentId }]); + + const first = new Operation(OperationType.Insert, index, { payload: 'a' }, userId); + + await client.send(first); + await client.send(new Operation(OperationType.Insert, index, { payload: 'b' }, userId)); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: new Operation(OperationType.Insert, index, { payload: 'a' }, userId, 1).serialize(), + }); + await flushMicrotasks(); + + /** + * The acknowledgement is what releases the queue. Nothing covered that it does, which left + * the gate free to latch shut and strand every later operation + */ + expect(sentOperations(sendSpy)).toHaveLength(2); + sendSpy.mockRestore(); + }); + + /** + * The guard on the acknowledgement handler now protects the pending queue, not just the + * gate: mistaking a collaborator's operation for the acknowledgement would drop a local + * operation that is still in flight, reopening the window remote writes are transformed in + */ + it('should not mistake a remote operation for the acknowledgement', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); + + const index = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + documentId }]); + + await client.send(new Operation(OperationType.Insert, index, { payload: 'a' }, userId)); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: new Operation(OperationType.Insert, Index.block(5, documentId), { payload: [] }, remoteUserId, 1).serialize(), + }); + await flushMicrotasks(); + + await client.send(new Operation(OperationType.Insert, index, { payload: 'b' }, userId)); + + // The first operation is still unacknowledged, so the second has to wait behind it. + expect(sentOperations(sendSpy)).toHaveLength(1); + sendSpy.mockRestore(); + }); + + it('should still send a later operation once the queue has drained', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); + + const index = Index.text([{ blockIndex: 0, + dataKey: createDataKey('text'), + textRange: [0, 0], + documentId }]); + + await client.send(new Operation(OperationType.Insert, index, { payload: 'a' }, userId)); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: new Operation(OperationType.Insert, index, { payload: 'a' }, userId, 1).serialize(), + }); + await flushMicrotasks(); + + await client.send(new Operation(OperationType.Insert, index, { payload: 'b' }, userId)); + + expect(sentOperations(sendSpy)).toHaveLength(2); + sendSpy.mockRestore(); + }); }); describe('close', () => { diff --git a/packages/collaboration-manager/src/client/OTClient.ts b/packages/collaboration-manager/src/client/OTClient.ts index 708a21be..95f565ca 100644 --- a/packages/collaboration-manager/src/client/OTClient.ts +++ b/packages/collaboration-manager/src/client/OTClient.ts @@ -18,7 +18,8 @@ export class OTClient { #rev: number = 0; /** - * Array of pending operations to send + * Operations the server has not acknowledged yet, oldest first. The one being sent stays at + * the head until its acknowledgement, so remote operations are transformed against it too */ #pendingOperations: Operation[] = []; @@ -156,18 +157,26 @@ export class OTClient { return; } - const nextOperation = this.#pendingOperations.shift(); - - if (!nextOperation) { + if (this.#pendingOperations.length === 0) { this.#awaitingAcknowledgement = false; return; } - const ws = await this.#ws; + /** + * Read, not removed: an operation is pending until acknowledged, and dropping it here left the round trip + * unguarded, so a remote write to the same value was applied over the local one and never corrected + */ + const nextOperation = this.#pendingOperations[0]; + /** + * Claimed before the first await, so that a concurrent `send` sees the flag and queues + * instead of reading the same head operation and sending it twice + */ this.#awaitingAcknowledgement = true; + const ws = await this.#ws; + /** * Handles acknowledgment response and sends the next operation * @param message - server message @@ -184,6 +193,11 @@ export class OTClient { return; } + /** + * Acknowledged, so it stops being pending. The server orders its messages, so whatever arrives after + * this was ordered after the local operation, and whatever arrived before it was ordered before + */ + this.#pendingOperations.shift(); this.#resolvedOperations.push(nextOperation); ws.removeEventListener('message', onMessage); diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index 317b8297..fc847bc3 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -8,6 +8,7 @@ import type ToolsManager from '../tools/ToolsManager'; import type { TextNodeSerialized } from '@editorjs/sdk'; import { EventBus, EventType, BlockAddedEvent, BlockRemovedEvent } from '@editorjs/sdk'; import { EditorJSModel } from '@editorjs/model'; +import type { OutputData } from 'editorjs-v2'; const USER_ID = 'integration-user'; const DOCUMENT_ID = 'integration-doc'; @@ -35,6 +36,7 @@ jest.unstable_mockModule('../tools/ToolsManager', () => ({ const ToolsManager = (await import('../tools/ToolsManager')).default; const { BlocksManager } = await import('../components/BlockManager.js'); const { BlocksAPI } = await import('./BlocksAPI.js'); +const { composeDataFromVersion2 } = await import('../utils/composeDataFromVersion2.js'); describe('BlocksAPI integration (real model, mocked tools)', () => { let model: InstanceType; @@ -63,10 +65,14 @@ describe('BlocksAPI integration (real model, mocked tools)', () => { config ); + /** + * The same model instance the manager uses: methods that read the document directly + * (getIdByIndex, getData, getPluginData) would otherwise see an empty document. + */ blocksAPI = new BlocksAPI( blocksManager, config, - new EditorJSModel('userId', { identifier: 'documentId' }) + model ); }); @@ -304,6 +310,37 @@ describe('BlocksAPI integration (real model, mocked tools)', () => { }); }); + describe('getToolByIndex()', () => { + it('should return the tool name of the block at that index', () => { + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ type: 'header' }); + + expect(blocksAPI.getToolByIndex(1)).toBe('header'); + }); + + it('should return undefined past the end of the document', () => { + blocksAPI.insert({ type: 'paragraph' }); + + expect(blocksAPI.getToolByIndex(1)).toBeUndefined(); + }); + + it('should return undefined for a negative index', () => { + blocksAPI.insert({ type: 'paragraph' }); + + expect(blocksAPI.getToolByIndex(-1)).toBeUndefined(); + }); + + it('should follow the block when it moves', () => { + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ type: 'header' }); + + blocksAPI.move({ toIndex: 0, + fromIndex: 1 }); + + expect(blocksAPI.getToolByIndex(0)).toBe('header'); + }); + }); + describe('render()', () => { it('should replace document content with the provided serialized data', () => { blocksAPI.insert({ type: 'paragraph' }); @@ -1183,4 +1220,188 @@ describe('BlocksAPI integration (real model, mocked tools)', () => { expect(model.serialized.blocks[0]).toEqual(expect.objectContaining({ name: 'list' })); }); }); + + describe('plugin data', () => { + it('should insert a block carrying both tool data and plugin data', () => { + blocksAPI.insert({ + type: 'paragraph', + data: { text: 'Alpha' }, + plugins: { anchors: { id: 'intro' } }, + }); + + expect(model.serialized.blocks[0]).toEqual(expect.objectContaining({ + name: 'paragraph', + data: expect.objectContaining({ text: 'Alpha' }), + plugins: { anchors: { id: 'intro' } }, + })); + }); + + it('should return undefined for a plugin that stores nothing on the block', () => { + blocksAPI.insert({ type: 'paragraph' }); + + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'anchors' })).toBeUndefined(); + }); + + it('should write and read plugin data by block index', () => { + blocksAPI.insert({ type: 'paragraph' }); + + blocksAPI.updatePluginData({ block: 0, + plugin: 'anchors', + data: { id: 'intro' } }); + + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'anchors' })).toEqual({ id: 'intro' }); + }); + + it('should write and read plugin data by block id', () => { + blocksAPI.insert({ type: 'paragraph' }); + + const blockId = blocksAPI.getIdByIndex(0)!; + + blocksAPI.updatePluginData({ block: blockId, + plugin: 'anchors', + data: { id: 'intro' } }); + + expect(blocksAPI.getPluginData({ block: blockId, + plugin: 'anchors' })).toEqual({ id: 'intro' }); + }); + + it('should return undefined once the last key of an entry is removed', () => { + blocksAPI.insert({ type: 'paragraph' }); + + blocksAPI.updatePluginData({ block: 0, + plugin: 'anchors', + data: { id: 'intro' } }); + blocksAPI.updatePluginData({ block: 0, + plugin: 'anchors', + data: { id: undefined } }); + + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'anchors' })).toBeUndefined(); + }); + + it('should return undefined for a plugin name that Object.prototype also answers to', () => { + blocksAPI.insert({ type: 'paragraph' }); + + blocksAPI.updatePluginData({ block: 0, + plugin: 'anchors', + data: { id: 'intro' } }); + + /** + * The serialized plugins map is an ordinary object, so indexing it directly hands back + * the inherited member for a plugin that stores nothing -- a function, where the caller + * is told to expect data or undefined + */ + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'toString' })).toBeUndefined(); + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'constructor' })).toBeUndefined(); + }); + + it('should hand back plugin data the caller can change without touching the document', () => { + blocksAPI.insert({ type: 'paragraph' }); + + blocksAPI.updatePluginData({ block: 0, + plugin: 'anchors', + data: { id: 'intro' } }); + + const data = blocksAPI.getPluginData({ block: 0, + plugin: 'anchors' }) as Record; + + data.id = 'changed'; + + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'anchors' })).toEqual({ id: 'intro' }); + }); + + it('should record a read-modify-write as a change', () => { + blocksAPI.insert({ type: 'paragraph' }); + + blocksAPI.updatePluginData({ block: 0, + plugin: 'anchors', + data: { id: 'intro' } }); + + const data = blocksAPI.getPluginData({ block: 0, + plugin: 'anchors' }) as Record; + + const events: unknown[] = []; + + model.addEventListener(EventType.Changed, e => events.push(e)); + + data.id = 'changed'; + blocksAPI.updatePluginData({ block: 0, + plugin: 'anchors', + data }); + + /** + * Reading, changing and writing back is the obvious way to use these two methods + * together. Over a live record it changed the document and emitted nothing, so the edit + * reached neither collaborators nor the undo stack + */ + expect(events).toHaveLength(1); + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'anchors' })).toEqual({ id: 'changed' }); + }); + + it('should attribute the change to the acting user', () => { + blocksAPI.insert({ type: 'paragraph' }); + + const events: unknown[] = []; + + model.addEventListener(EventType.Changed, e => events.push(e)); + + blocksAPI.updatePluginData({ block: 0, + plugin: 'anchors', + data: { id: 'intro' }, + userId: 'other-user' }); + + expect(events[0]).toHaveProperty('detail.userId', 'other-user'); + }); + + it('should throw for a block id that does not exist', () => { + expect(() => blocksAPI.getPluginData({ block: 'missing', + plugin: 'anchors' })).toThrow('missing'); + }); + + it('should expose tune data from a converted v2 document through getPluginData', () => { + /** + * The path a v2 document actually takes on boot: `composeDataFromVersion2` maps its `tunes` + * onto `plugins`, and the model is initialized from the result. `level` is used instead of a + * text field so the conversion needs no DOMParser, which this test environment lacks. + */ + const { blocks } = composeDataFromVersion2({ + blocks: [ + { + type: 'header', + data: { level: 2 }, + tunes: { + anchors: { id: 'intro' }, + alignment: 'left', + }, + }, + ], + } as OutputData); + + model.initializeDocument({ blocks }); + + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'anchors' })).toEqual({ id: 'intro' }); + expect(blocksAPI.getPluginData({ block: 0, + plugin: 'alignment' })).toEqual({ value: 'left' }); + }); + + it('should preserve plugin data when a block is moved', () => { + blocksAPI.insert({ type: 'paragraph', + plugins: { anchors: { id: 'first' } } }); + blocksAPI.insert({ type: 'paragraph' }); + + blocksAPI.move({ fromIndex: 0, + toIndex: 1 }); + + expect(model.serialized.blocks[1]).toEqual(expect.objectContaining({ + plugins: { anchors: { id: 'first' } }, + })); + }); + }); }); diff --git a/packages/core/src/api/BlocksAPI.spec.ts b/packages/core/src/api/BlocksAPI.spec.ts index 42499fad..883e05aa 100644 --- a/packages/core/src/api/BlocksAPI.spec.ts +++ b/packages/core/src/api/BlocksAPI.spec.ts @@ -191,6 +191,9 @@ describe('BlocksAPI', () => { data: {}, index: undefined, replace: undefined, + focus: undefined, + plugins: undefined, + userId: undefined, }); }); @@ -208,6 +211,7 @@ describe('BlocksAPI', () => { focus: true, replace: true, id: 'id-1', + plugins: undefined, }); expect(blocksManager.insert).toHaveBeenCalledWith({ @@ -216,6 +220,8 @@ describe('BlocksAPI', () => { index: 2, replace: true, focus: true, + plugins: undefined, + userId: undefined, }); }); }); diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index 696cf72f..9b00f59d 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -3,6 +3,7 @@ import { inject, injectable } from 'inversify'; import { TOKENS } from '../tokens.js'; import { BlocksManager } from '../components/BlockManager.js'; import { CoreConfigValidated, BlocksAPI as BlocksApiInterface, EditorDocumentSerialized, BlockId, BlockIndexOrId, createBlockId, createDataKey } from '@editorjs/sdk'; +import type { PluginDataFor, PluginId } from '@editorjs/sdk'; import { EditorJSModel } from '@editorjs/model'; import type { TextNodeSerialized, ValueSerialized } from '@editorjs/sdk'; @@ -105,9 +106,10 @@ export class BlocksAPI implements BlocksApiInterface { * @param [params.index] - index to insert block at * @param [params.focus] - flag indicates if new block should be focused @todo implement * @param [params.replace] - flag indicates if block at index should be replaced @todo implement + * @param [params.plugins] - initial per-plugin data for the block, keyed by plugin name * @param [params.userId] - user id to attribute the change to */ - public insert({ type, data, index, focus, replace, userId = this.#config.userId }: NonNullable[0]> = {}): void { + public insert({ type, data, index, focus, replace, plugins, userId = this.#config.userId }: NonNullable[0]> = {}): void { const blockType = type ?? this.#config.defaultBlock; const blockData = data ?? {}; @@ -117,6 +119,7 @@ export class BlocksAPI implements BlocksApiInterface { index, replace, focus, + plugins, userId, }); }; @@ -219,4 +222,35 @@ export class BlocksAPI implements BlocksApiInterface { }: Parameters[0]): void { this.#blocksManager.convertBlock(block as BlockIndexOrId, createDataKey(key), newType, userId, dataOverrides); } + + /** + * Returns the name of the tool rendering the block at the given index, or undefined when no + * block is there + * @param index - index of the block + */ + public getToolByIndex(index: number): string | undefined { + return this.#blocksManager.getToolByIndex(index); + } + + /** + * Returns the per-block data stored by the given plugin, or undefined when it stores none + * @param params - getPluginData parameters + * @param params.block - index or id of the block + * @param params.plugin - name the data is stored under, by convention the plugin's `name` + */ + public getPluginData({ block, plugin }: Parameters[0]): PluginDataFor | undefined { + return this.#blocksManager.getPluginData(block as BlockIndexOrId, plugin) as PluginDataFor | undefined; + } + + /** + * Merges the passed keys into the per-block data of the given plugin + * @param params - updatePluginData parameters + * @param params.block - index or id of the block + * @param params.plugin - name the data is stored under, by convention the plugin's `name` + * @param params.data - keys to merge into the plugin's data + * @param [params.userId] - user id to attribute the change to + */ + public updatePluginData({ block, plugin, data, userId = this.#config.userId }: Parameters[0]): void { + this.#blocksManager.updatePluginData(block as BlockIndexOrId, plugin, data as Record, userId); + } } diff --git a/packages/core/src/components/BlockManager.spec.ts b/packages/core/src/components/BlockManager.spec.ts index 3c89e057..1a94a2f7 100644 --- a/packages/core/src/components/BlockManager.spec.ts +++ b/packages/core/src/components/BlockManager.spec.ts @@ -12,6 +12,7 @@ jest.unstable_mockModule('@editorjs/sdk', () => ({ BlockChildType: { Text: 't' }, NODE_TYPE_HIDDEN_PROP: '$t', createBlockId: jest.fn((id: string) => id as never), + createPluginDataName: jest.fn((name: string) => name as never), set: jest.fn(() => undefined), renumberKeys: jest.fn(() => new Map()), TextIndex: class TextIndex {}, @@ -100,6 +101,44 @@ describe('BlocksManager (unit, mocked deps)', () => { expect(model.removeBlock).not.toHaveBeenCalled(); }); + it('should nest the block data under a `data` key rather than spreading it', () => { + blocksManager.insert({ type: 'paragraph', + data: { text: 'Alpha' } }); + + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + expect.objectContaining({ + name: 'paragraph', + data: { text: 'Alpha' }, + }), + BLOCKS_COUNT + ); + expect(model.addBlock).not.toHaveBeenCalledWith( + USER_ID, + expect.objectContaining({ text: 'Alpha' }), + BLOCKS_COUNT + ); + }); + + it('should forward the plugins map when one is passed', () => { + blocksManager.insert({ type: 'paragraph', + plugins: { anchors: { id: 'intro' } } }); + + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + expect.objectContaining({ plugins: { anchors: { id: 'intro' } } }), + BLOCKS_COUNT + ); + }); + + it('should omit the plugins key entirely when no plugin data is passed', () => { + blocksManager.insert({ type: 'paragraph' }); + + const blockInit = (model.addBlock as jest.Mock).mock.calls[0][1] as Record; + + expect('plugins' in blockInit).toBe(false); + }); + it('should use explicit index when provided', () => { blocksManager.insert({ index: 2, @@ -726,6 +765,66 @@ describe('BlocksManager (unit, mocked deps)', () => { ); }); + it('should carry the block plugin data over to the converted block', () => { + model.getBlockSerialized = jest.fn(() => ({ + name: 'header', + id: 'b1', + data: { + text: { + value: 'Hello', + fragments: [] + } + }, + plugins: { anchors: { id: 'intro' } } + })); + + const sourceTool = { exportTextContent: jest.fn(() => 'Hello') }; + const targetTool = { + importTextContent: jest.fn(() => ({ + text: { + value: 'Hello', + fragments: [] + } + })) + }; + + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'header' ? sourceTool : targetTool + ); + + blocksManager.convertBlock(0, 'text' as DataKey, 'paragraph'); + + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + expect.objectContaining({ plugins: { anchors: { id: 'intro' } } }), + 0 + ); + }); + + it('should omit the plugins key when the source block has no plugin data', () => { + const sourceTool = { exportTextContent: jest.fn(() => 'Hello') }; + const targetTool = { + importTextContent: jest.fn(() => ({ + text: { + value: 'Hello', + fragments: [] + } + })) + }; + + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'header' ? sourceTool : targetTool + ); + + blocksManager.convertBlock(0, 'text' as DataKey, 'paragraph'); + + const blockInit = (model.addBlock as jest.Mock).mock.calls[0][1] as Record; + + expect('plugins' in blockInit).toBe(false); + }); + it('should merge dataOverrides on top of the imported data', () => { const sourceTool = { exportTextContent: jest.fn(() => 'Hello') }; const targetTool = { diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index ee0f3a47..6ca5d6f0 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -8,6 +8,8 @@ import { type EditorDocumentSerialized, type InlineTreeNodeSerialized, NODE_TYPE_HIDDEN_PROP, + createPluginDataName, + type PluginDataSerialized, renumberKeys, set, TextIndex @@ -35,6 +37,10 @@ interface InsertBlockParameters { * Block's initial data */ data?: BlockToolData; + /** + * Initial per-plugin data for the block, keyed by plugin name + */ + plugins?: Record; /** * Index to insert block at */ @@ -49,7 +55,6 @@ interface InsertBlockParameters { * If true, moves caret to the new block */ focus?: boolean; - // tunes?: {[name: string]: BlockTuneData}; /** * User id to attribute the change to */ @@ -131,8 +136,8 @@ export class BlocksManager { index, focus = false, replace = false, + plugins, userId = this.#config.userId, - // tunes = {}, }: InsertBlockParameters = {}): void { let newIndex = index; @@ -148,6 +153,7 @@ export class BlocksManager { id, name: type, data, + ...(plugins !== undefined && { plugins }), }, newIndex); if (focus) { @@ -426,9 +432,60 @@ export class BlocksManager { this.#model.addBlock(userId, { name: newType, data: finalData, + ...(block.plugins !== undefined && { plugins: block.plugins }), }, blockIndex); } + /** + * Returns the name of the tool rendering the block at the given index, or undefined when the + * index names no block. Out of bounds reports rather than throws, matching `getIdByIndex` + * @param index - position of the block in the document + */ + public getToolByIndex(index: number): string | undefined { + if (index < 0 || index >= this.blocksCount) { + return undefined; + } + + return this.#model.getBlockSerialized(index).name; + } + + /** + * Returns the per-block data stored by the given plugin, or undefined when it stores none + * @param blockIndexOrId - position of the block in the document, or its id + * @param pluginName - key the data is stored under, by convention the owning plugin's `name` + */ + public getPluginData(blockIndexOrId: number | BlockId, pluginName: string): PluginDataSerialized | undefined { + const blockIndex = this.#model.resolveBlockIndex(blockIndexOrId); + const plugins = this.#model.getBlockSerialized(blockIndex).plugins; + + /** + * Checked for an own entry rather than indexed directly: the serialized map is an ordinary + * object, so a plugin named `toString` or `constructor` that stores nothing here would + * otherwise read back as the inherited function instead of `undefined` + */ + if (plugins === undefined || !Object.prototype.hasOwnProperty.call(plugins, pluginName)) { + return undefined; + } + + return plugins[pluginName]; + } + + /** + * Merges the passed keys into the per-block data of the given plugin + * @param blockIndexOrId - position of the block in the document, or its id + * @param pluginName - key the data is stored under, by convention the owning plugin's `name` + * @param data - keys to merge in; a key set to undefined is removed + * @param userId - person the change is attributed to + */ + public updatePluginData( + blockIndexOrId: number | BlockId, + pluginName: string, + data: Record, + userId: string | number = this.#config.userId + ): void { + this.#model.updatePluginData(userId, blockIndexOrId, createPluginDataName(pluginName), data); + } + /** * Returns block index where user caret is placed */ diff --git a/packages/core/src/components/UndoRedoManager.integration.spec.ts b/packages/core/src/components/UndoRedoManager.integration.spec.ts new file mode 100644 index 00000000..5769c833 --- /dev/null +++ b/packages/core/src/components/UndoRedoManager.integration.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import type { CoreConfigValidated } from '@editorjs/sdk'; +import { EventBus, createPluginDataName } from '@editorjs/sdk'; +import { EditorJSModel } from '@editorjs/model'; +import { UndoRedoManager } from './UndoRedoManager.js'; + +const USER_ID = 'undo-user'; +const DOCUMENT_ID = 'undo-doc'; +const PLUGIN = createPluginDataName('anchors'); + +/** + * Reads the plugin's serialized data for the first block, or undefined when it stores none + * @param model - model to read from + */ +function pluginData(model: InstanceType): Record | undefined { + return model.serialized.blocks[0].plugins?.[PLUGIN]; +} + +describe('UndoRedoManager integration with plugin data (real model)', () => { + let model: InstanceType; + let manager: InstanceType; + + beforeEach(() => { + model = new EditorJSModel(USER_ID, { identifier: DOCUMENT_ID }); + + model.initializeDocument({ + blocks: [ + { + name: 'paragraph', + data: {}, + }, + ], + }); + + manager = new UndoRedoManager( + model, + new EventBus(), + { userId: USER_ID } as CoreConfigValidated + ); + }); + + it('should undo the first write by removing the entry, and redo by restoring it', () => { + model.updatePluginData(USER_ID, 0, PLUGIN, { id: 'intro' }); + + expect(pluginData(model)).toEqual({ id: 'intro' }); + + manager.undo(); + + expect(pluginData(model)).toBeUndefined(); + + manager.redo(); + + expect(pluginData(model)).toEqual({ id: 'intro' }); + }); + + it('should undo an update back to the previous value', () => { + model.updatePluginData(USER_ID, 0, PLUGIN, { visible: true }); + model.updatePluginData(USER_ID, 0, PLUGIN, { visible: false }); + + expect(pluginData(model)).toEqual({ visible: false }); + + manager.undo(); + + expect(pluginData(model)).toEqual({ visible: true }); + }); + + it('should record nothing for a write that stores what is already stored', () => { + model.updatePluginData(USER_ID, 0, PLUGIN, { id: 'intro' }); + model.updatePluginData(USER_ID, 0, PLUGIN, { visible: true }); + + /** + * A provider re-applying the state it just read, which is what happens every time a menu + * item derives `isActive` from plugin data and the user activates it twice + */ + model.updatePluginData(USER_ID, 0, PLUGIN, { visible: true }); + + manager.undo(); + + expect(pluginData(model)).toEqual({ id: 'intro' }); + }); + + it('should leave other keys of the same plugin untouched when undoing one key', () => { + model.updatePluginData(USER_ID, 0, PLUGIN, { id: 'intro' }); + model.updatePluginData(USER_ID, 0, PLUGIN, { visible: true }); + + manager.undo(); + + expect(pluginData(model)).toEqual({ id: 'intro' }); + }); +}); diff --git a/packages/core/src/index.spec.ts b/packages/core/src/index.spec.ts index 3338540a..5b525f6b 100644 --- a/packages/core/src/index.spec.ts +++ b/packages/core/src/index.spec.ts @@ -1,7 +1,8 @@ /* eslint-disable jsdoc/require-jsdoc -- inline test stubs */ -import { describe, it, expect } from '@jest/globals'; +import { afterEach, describe, it, expect, jest } from '@jest/globals'; import { PluginType, ToolType } from '@editorjs/sdk'; import type { CoreConfig } from '@editorjs/sdk'; +import ToolsManager from './tools/ToolsManager.js'; import Core from './index.js'; /** @@ -56,6 +57,22 @@ class StubPlugin { } } +/** + * Stands in for a former Block Tune: a plain plugin that contributes an item to the + * block settings menu. It carries no `type`, so `use()` must route it to the plugins + * container rather than to any tool collection. + */ +class StubSettingsPlugin { + public static type = PluginType.Plugin as const; + public static name = 'delete-block'; + + public static instances = 0; + + constructor() { + StubSettingsPlugin.instances += 1; + } +} + describe('Core', () => { describe('initialize() preconditions', () => { it('should throw naming the rendering adapter when no adapter has been registered', async () => { @@ -107,4 +124,39 @@ describe('Core', () => { expect(StubPlugin.instances).toBe(1); }); }); + + describe('registering a former block tune', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should instantiate a block settings plugin as a plugin and keep it out of the tools handed to ToolsManager', async () => { + StubSettingsPlugin.instances = 0; + + const prepareTools = jest.spyOn(ToolsManager.prototype, 'prepareTools'); + const core = new Core(createConfig()); + + core.use(StubAdapter); + core.use(StubBlockTool); + core.use(StubSettingsPlugin); + + await core.initialize(); + + const prepared = prepareTools.mock.calls[0][0]; + + expect(StubSettingsPlugin.instances).toBe(1); + expect(prepared.map(([tool]) => tool.name)).toEqual(['paragraph']); + }); + + it('should no longer expose a blockTunes collection on ToolsManager', () => { + type ManagerCollections = { + // @ts-expect-error -- `blockTunes` went with the tune tool kind + blockTunes: ToolsManager['blockTunes']; + }; + + const probe: ManagerCollections | undefined = undefined; + + expect(probe).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 01ab27ae..52fcf44d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,7 +16,7 @@ import { } from '@editorjs/sdk'; import { composeDataFromVersion2 } from './utils/composeDataFromVersion2.js'; import ToolsManager from './tools/ToolsManager.js'; -import type { CoreConfigValidated, CoreConfig, EditorjsPluginConstructor, BlockTuneConstructor, ToolConstructable, EditorjsAdapterPluginConstructor } from '@editorjs/sdk'; +import type { CoreConfigValidated, CoreConfig, EditorjsPluginConstructor, ToolConstructable, EditorjsAdapterPluginConstructor } from '@editorjs/sdk'; import { EditorAPI } from './api/index.js'; import { generateId } from './utils/uid.js'; import { BlocksManager } from './components/BlockManager.js'; @@ -134,7 +134,6 @@ export default class Core { switch (pluginType) { case ToolType.Block: case ToolType.Inline: - case ToolType.Tune: this.#plugins.bind<[ToolConstructable, ToolStaticOptions | undefined]>(pluginType).toConstantValue([pluginOrTool as ToolConstructable, options]); break; case PluginType.Adapter: @@ -218,11 +217,10 @@ export default class Core { async #initializeTools(): Promise { const blockTools = this.#plugins.getAll<[BlockToolConstructor, ToolStaticOptions | undefined]>(ToolType.Block); const inlineTools = this.#plugins.getAll<[InlineToolConstructor, ToolStaticOptions | undefined]>(ToolType.Inline); - const blockTunes = this.#plugins.getAll<[BlockTuneConstructor, ToolStaticOptions | undefined]>(ToolType.Tune); const toolsManager = this.#iocContainer.get(ToolsManager); - return toolsManager.prepareTools([...blockTools, ...inlineTools, ...blockTunes]); + return toolsManager.prepareTools([...blockTools, ...inlineTools]); } /** diff --git a/packages/core/src/tools/ToolsFactory.spec.ts b/packages/core/src/tools/ToolsFactory.spec.ts new file mode 100644 index 00000000..4dd3672a --- /dev/null +++ b/packages/core/src/tools/ToolsFactory.spec.ts @@ -0,0 +1,66 @@ +/* eslint-disable jsdoc/require-jsdoc -- inline test stubs */ +import { describe, it, expect } from '@jest/globals'; +import type { EditorAPI, ToolConstructable, ToolStaticOptions } from '@editorjs/sdk'; +import { BlockToolFacade, InlineToolFacade, ToolType } from '@editorjs/sdk'; +import type { EditorConfig } from 'editorjs-v2'; +import { ToolsFactory } from './ToolsFactory.js'; + +class StubBlockTool { + public static type = ToolType.Block as const; + public static name = 'paragraph'; +} + +class StubInlineTool { + public static type = ToolType.Inline as const; + public static name = 'bold'; +} + +/** + * Builds a factory holding both stub tools, optionally registering the block tool + * with `use()`-time options. The factory only reads `defaultBlock` + * and `placeholder` off the editor config, so a partial object stands in for it, + * and `get()` never instantiates a tool — so the stubs implement no tool methods + * and are cast to satisfy the registration signature. + * @param blockToolSettings - second argument of `use()` for the block tool + */ +function createFactory(blockToolSettings?: ToolStaticOptions): ToolsFactory { + const factory = new ToolsFactory( + {}, + { defaultBlock: 'paragraph' } as EditorConfig, + {} as EditorAPI + ); + + factory.setTools([ + [StubBlockTool as unknown as ToolConstructable, blockToolSettings], + [StubInlineTool as unknown as ToolConstructable, undefined], + ]); + + return factory; +} + +describe('ToolsFactory', () => { + describe('.get()', () => { + it('should wrap a block tool in a BlockToolFacade', () => { + expect(createFactory().get('paragraph')).toBeInstanceOf(BlockToolFacade); + }); + + it('should wrap an inline tool in an InlineToolFacade', () => { + expect(createFactory().get('bold')).toBeInstanceOf(InlineToolFacade); + }); + + it('should mark the configured defaultBlock tool as default', () => { + expect(createFactory().get('paragraph').isDefault).toBe(true); + expect(createFactory().get('bold').isDefault).toBe(false); + }); + + it('should hand the registration options to the facade it builds', () => { + const factory = createFactory({ config: { placeholder: 'Type here' } }); + + expect(factory.get('paragraph').config).toEqual({ placeholder: 'Type here' }); + }); + + it('should throw naming a tool that was never registered', () => { + expect(() => createFactory().get('header')).toThrow('Tool header is not registered'); + }); + }); +}); diff --git a/packages/core/src/tools/ToolsFactory.ts b/packages/core/src/tools/ToolsFactory.ts index 8fbe9362..0ea206d5 100644 --- a/packages/core/src/tools/ToolsFactory.ts +++ b/packages/core/src/tools/ToolsFactory.ts @@ -4,14 +4,13 @@ import { ToolType } from '@editorjs/sdk'; import type { ToolConstructable, ToolStaticOptions } from '@editorjs/sdk'; import { InlineToolFacade, - BlockTuneFacade, BlockToolFacade } from '@editorjs/sdk'; import type { EditorConfig } from 'editorjs-v2'; -type ToolConstructor = typeof InlineToolFacade | typeof BlockToolFacade | typeof BlockTuneFacade; +type ToolConstructor = typeof InlineToolFacade | typeof BlockToolFacade; /** * Full tool registration entry: the constructor class plus its options. @@ -81,7 +80,7 @@ export class ToolsFactory { * Returns Tool object based on it's type * @param name - tool name */ - public get(name: string): InlineToolFacade | BlockToolFacade | BlockTuneFacade { + public get(name: string): InlineToolFacade | BlockToolFacade { const toolSettings = this.#toolsSettings.get(name); if (!toolSettings) { @@ -91,7 +90,6 @@ export class ToolsFactory { const { class: constructable, ...useToolOptions } = toolSettings; const Constructor = this.#getConstructor(constructable); - // const isTune = constructable[InternalTuneSettings.IsTune]; return new Constructor({ name, @@ -115,8 +113,6 @@ export class ToolsFactory { switch (constructable.type) { case ToolType.Inline: return InlineToolFacade; - case ToolType.Tune: - return BlockTuneFacade; default: return BlockToolFacade; } diff --git a/packages/core/src/tools/ToolsManager.ts b/packages/core/src/tools/ToolsManager.ts index 297f30fd..6b4e968b 100644 --- a/packages/core/src/tools/ToolsManager.ts +++ b/packages/core/src/tools/ToolsManager.ts @@ -9,7 +9,7 @@ import type { import { InlineTool, ToolLoadedCoreEvent, - BlockToolFacade, BlockTuneFacade, + BlockToolFacade, InlineToolFacade, ToolFacadeClass, ToolsCollection, @@ -84,14 +84,6 @@ export default class ToolsManager { return this.available.blockTools; } - /** - * Return available Block Tunes - * @returns - object of Inline Tool's classes - */ - public get blockTunes(): ToolsCollection { - return this.available.blockTunes; - } - /** * @param editorConfig - EditorConfig object * @param apiFactory - Editor API factory function diff --git a/packages/core/src/utils/composeDataFromVersion2.spec.ts b/packages/core/src/utils/composeDataFromVersion2.spec.ts new file mode 100644 index 00000000..eef01531 --- /dev/null +++ b/packages/core/src/utils/composeDataFromVersion2.spec.ts @@ -0,0 +1,172 @@ +import { afterAll, beforeAll, describe, it, expect } from '@jest/globals'; +import type { OutputData } from 'editorjs-v2'; +import { composeDataFromVersion2 } from './composeDataFromVersion2.js'; + +/** + * Plugin name that collides with an Object.prototype member, kept in a constant so the + * literal doesn't trip the naming-convention lint rule + */ +const PROTO_NAME = '__proto__'; + +/** + * Builds v2 output data with a single paragraph block + * @param block - fields to merge into that block + */ +function v2Data(block: Partial = {}): OutputData { + return { + blocks: [ + { + type: 'paragraph', + data: { text: 'Alpha' }, + ...block, + }, + ], + } as OutputData; +} + +/** + * Node type constants as the DOM exposes them, needed by the parser stub below + */ +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +/** + * Minimal stand-in for what the converter reads off a parsed document + */ +interface ParsedDocumentStub { + /** + * Body whose children the converter walks to collect inline fragments + */ + body: { + /** + * Child nodes of the body; the stub yields one text node + */ + childNodes: unknown[]; + }; +} + +/** + * Stubs the DOMParser the converter uses to pull inline fragments out of v2 HTML strings, since + * this test environment has no jsdom. Only the traversal surface the converter touches is + * provided: a body of plain text nodes, so a string round-trips as text carrying no fragments. + * @returns function restoring the previous globals + */ +function mockDOMParser(): () => void { + const globals = globalThis as unknown as Record; + const originalParser = globals.DOMParser; + const originalNode = globals.Node; + + const parserStub = class { + /** + * Wraps the whole string in a single text node + * @param html - markup the converter hands over + * @returns document-like object the converter can walk + */ + public parseFromString(html: string): ParsedDocumentStub { + return { + body: { + childNodes: [{ nodeType: TEXT_NODE, + textContent: html }], + }, + }; + } + }; + + globals.DOMParser = parserStub; + globals.Node = Object.fromEntries([ + ['TEXT_NODE', TEXT_NODE], + ['ELEMENT_NODE', ELEMENT_NODE], + ]); + + return () => { + globals.DOMParser = originalParser; + globals.Node = originalNode; + }; +} + +describe('composeDataFromVersion2', () => { + let restoreDOMParser: () => void; + + beforeAll(() => { + restoreDOMParser = mockDOMParser(); + }); + + afterAll(() => { + restoreDOMParser(); + }); + + describe('block data', () => { + it('should convert a string value into a text node', () => { + const { blocks } = composeDataFromVersion2(v2Data()); + + expect(blocks[0].name).toBe('paragraph'); + expect(blocks[0].data).toEqual({ + text: expect.objectContaining({ value: 'Alpha' }), + }); + }); + + it('should keep a non-string value as a serialized value node', () => { + const { blocks } = composeDataFromVersion2(v2Data({ data: { level: 2 } })); + + expect(blocks[0].data).toEqual({ level: 2 }); + }); + }); + + describe('tune data', () => { + it('should map a tune into plugin data under the same key', () => { + const { blocks } = composeDataFromVersion2(v2Data({ tunes: { anchors: { id: 'intro' } } })); + + expect(blocks[0].plugins).toEqual({ anchors: { id: 'intro' } }); + }); + + it('should map several tunes independently', () => { + const { blocks } = composeDataFromVersion2(v2Data({ + tunes: { + anchors: { id: 'intro' }, + footnotes: { count: 2 }, + }, + })); + + expect(blocks[0].plugins).toEqual({ + anchors: { id: 'intro' }, + footnotes: { count: 2 }, + }); + }); + + it('should store a primitive tune value under the `value` key', () => { + const { blocks } = composeDataFromVersion2(v2Data({ tunes: { alignment: 'left' } })); + + expect(blocks[0].plugins).toEqual({ alignment: { value: 'left' } }); + }); + + it('should store an array tune value under the `value` key', () => { + const { blocks } = composeDataFromVersion2(v2Data({ tunes: { list: [1, 2] } })); + + expect(blocks[0].plugins).toEqual({ list: { value: [1, 2] } }); + }); + + it('should store a null tune value under the `value` key', () => { + const { blocks } = composeDataFromVersion2(v2Data({ tunes: { maybe: null } })); + + expect(blocks[0].plugins).toEqual({ maybe: { value: null } }); + }); + + it('should produce no plugins key for a block without tunes', () => { + const { blocks } = composeDataFromVersion2(v2Data()); + + expect('plugins' in blocks[0]).toBe(false); + }); + + it('should produce no plugins key for an empty tunes map', () => { + const { blocks } = composeDataFromVersion2(v2Data({ tunes: {} })); + + expect('plugins' in blocks[0]).toBe(false); + }); + + it('should keep a tune whose name collides with an object prototype member', () => { + const { blocks } = composeDataFromVersion2(v2Data({ tunes: { [PROTO_NAME]: { id: 'intro' } } })); + + expect(blocks[0].plugins).toEqual({ [PROTO_NAME]: { id: 'intro' } }); + }); + }); +}); diff --git a/packages/core/src/utils/composeDataFromVersion2.ts b/packages/core/src/utils/composeDataFromVersion2.ts index 9d096f5d..0660dcbb 100644 --- a/packages/core/src/utils/composeDataFromVersion2.ts +++ b/packages/core/src/utils/composeDataFromVersion2.ts @@ -1,7 +1,7 @@ -import type { OutputData } from 'editorjs-v2'; +import type { OutputBlockData, OutputData } from 'editorjs-v2'; import { TextNode, ValueNode } from '@editorjs/model'; import type { InlineFragment } from '@editorjs/sdk'; -import { createInlineToolData, createInlineToolName, type BlockNodeInit } from '@editorjs/sdk'; +import { createInlineToolData, createInlineToolName, type BlockNodeInit, type PluginDataSerialized } from '@editorjs/sdk'; /** * Removes HTML tags from the input string @@ -85,6 +85,33 @@ function extractFragments(html: string): InlineFragment[] { return fragments; } +/** + * Key a non-object tune value is stored under, since plugin data is a key/value map + * while v2 tune data may be any JSON value + */ +const SCALAR_TUNE_KEY = 'value'; + +/** + * Maps a v2 block's tunes onto v3 per-plugin data, keeping names verbatim so a v3 plugin that + * adopts a v2 tune's name finds its data. Object data is copied key by key; anything else goes + * under `value`, since a plugin entry is a key/value map. + * @param tunes - `tunes` map of a v2 block + */ +function composePluginsFromTunes(tunes: NonNullable): Record { + return Object.fromEntries( + Object.entries(tunes).map(([tuneName, tuneData]) => { + const isPlainObject = typeof tuneData === 'object' && tuneData !== null && !Array.isArray(tuneData); + + return [ + tuneName, + isPlainObject + ? { ...tuneData as Record } + : { [SCALAR_TUNE_KEY]: tuneData }, + ]; + }) + ) as Record; +} + /** * Converst OutputData from version 2 to version 3 * @param data - OutputData from version 2 @@ -97,8 +124,13 @@ export function composeDataFromVersion2(data: OutputData): { } { return { blocks: data.blocks.map((block) => { + const plugins = block.tunes !== undefined && Object.keys(block.tunes).length > 0 + ? composePluginsFromTunes(block.tunes) + : undefined; + return { name: block.type, + ...(plugins !== undefined && { plugins }), data: Object.fromEntries( Object .entries(block.data as Record) diff --git a/packages/editorjs/e2e/tests/axe.spec.ts b/packages/editorjs/e2e/tests/axe.spec.ts index e3d4decb..e1394dd2 100644 --- a/packages/editorjs/e2e/tests/axe.spec.ts +++ b/packages/editorjs/e2e/tests/axe.spec.ts @@ -54,6 +54,19 @@ test('has no automatically detectable accessibility violations with the toolbox expect(await auditEditor(page)).toEqual([]); }); +test('has no automatically detectable accessibility violations with block settings open', async ({ page }) => { + // The settings menu is the one popover whose items are *not* native controls -- they are + // divs carrying role="menuitem", which is what the ARIA menu pattern calls for and what the + // toolbox does too. That makes every accessible name, state and role on them authored rather + // than inherited, so it is worth auditing the menu in its open state. + await page.getByRole('textbox', { name: 'Paragraph' }).first() + .hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + await expect(page.getByRole('menu', { name: 'Block settings' })).toBeVisible(); + + expect(await auditEditor(page)).toEqual([]); +}); + test('has no automatically detectable accessibility violations with the inline toolbar open', async ({ page }) => { await selectParagraphText(page); await expect(page.getByRole('toolbar', { name: 'Text formatting' })).toBeVisible(); diff --git a/packages/editorjs/e2e/tests/block-settings.spec.ts b/packages/editorjs/e2e/tests/block-settings.spec.ts new file mode 100644 index 00000000..9d085cb7 --- /dev/null +++ b/packages/editorjs/e2e/tests/block-settings.spec.ts @@ -0,0 +1,272 @@ +import { test, expect } from '@playwright/test'; +import { mountDocument } from '../support/editor.js'; + +/** The three entries the default block settings plugin contributes. */ +const DEFAULT_ITEM_COUNT = 3; + +/** Slack, in pixels, when checking the toolbar has lined up with a block. */ +const ALIGNMENT_TOLERANCE = 4; + +/** + * The toolbar follows whichever block the pointer is over, so a test opens settings for a + * particular block by hovering it first and then clicking the toolbar's settings button. + */ + +test.beforeEach(async ({ page }) => { + await mountDocument(page, '?text=Alpha&text=Beta'); +}); + +test.describe('opening the menu', () => { + test('opens the settings menu for the hovered block', async ({ page }) => { + const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + + await blocks.nth(1).hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + + // Named from the message catalogue, the same way the toolbox menu is. + const menu = page.getByRole('menu', { name: 'Block settings' }); + + await expect(menu).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: 'Move up' })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: 'Move down' })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: 'Delete' })).toBeVisible(); + }); + + test('reports the menu state on the button that owns it', async ({ page }) => { + const settingsButton = page.getByRole('button', { name: 'Block settings' }); + + await page.getByRole('textbox', { name: 'Paragraph' }).first() + .hover(); + + await expect(settingsButton).toHaveAttribute('aria-haspopup', 'menu'); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); + + await settingsButton.click(); + + await expect(settingsButton).toHaveAttribute('aria-expanded', 'true'); + + await page.keyboard.press('Escape'); + + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); + + // The menu button pattern hands focus back to the button the menu was opened from. + await expect(settingsButton).toBeFocused(); + }); + + test('closes the menu when the button that opened it is clicked again', async ({ page }) => { + const settingsButton = page.getByRole('button', { name: 'Block settings' }); + const menu = page.getByRole('menu', { name: 'Block settings' }); + + await page.getByRole('textbox', { name: 'Paragraph' }).first() + .hover(); + + await settingsButton.click(); + await expect(menu).toBeVisible(); + + // The popover closes itself on this click before the button's own handler runs, so what + // this asserts is that the handler does not immediately re-open it -- which is what made + // the button look inert on every second click. + await settingsButton.click(); + + await expect(menu).toBeHidden(); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); + + await settingsButton.click(); + + await expect(menu).toBeVisible(); + }); + + test('opens for the caret block without the pointer ever entering one', async ({ page }) => { + const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + const toolbar = page.getByRole('toolbar', { name: 'Block actions' }); + + // Block selection is dispatched on pointer enter, so nothing here may hover a block: this + // is the path a keyboard-only user takes, and the button used to open nothing at all. + await page.keyboard.press('Tab'); + + // Waited for rather than assumed: the arrow keys below are only caret moves once the + // editor actually holds focus, and pressing them early leaves the document untouched. + await expect(page.getByRole('group')).toBeFocused(); + + // Tab reaches the editor but places no caret in a block; an arrow key is what does. + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('!'); + + // Typing is what says where the caret actually is, rather than assuming the arrows put it + // there. The second block is the target, so the toolbar has somewhere visible to move to. + await expect(blocks).toHaveText(['Alpha', '!Beta']); + + // The browser delivers `selectionchange` on its own schedule, so the caret reaching the + // editor and the toolbar hearing about it are two different moments. Waiting for the + // toolbar to line up with the second block waits for the second -- and is itself the other + // half of what this fixes, the toolbar following a caret that no pointer led. + await expect.poll(async () => { + const toolbarBox = await toolbar.boundingBox(); + const blockBox = await blocks.nth(1).boundingBox(); + + if (toolbarBox === null || blockBox === null) { + return false; + } + + return Math.abs(toolbarBox.y - blockBox.y) < ALIGNMENT_TOLERANCE; + }).toBe(true); + + // `press` on the locator rather than `focus()` then `keyboard.press`: the two-step form + // races, and an Enter that arrives before focus lands goes to the editor as a new block. + await page.getByRole('button', { name: 'Block settings' }).press('Enter'); + + const menu = page.getByRole('menu', { name: 'Block settings' }); + + await expect(menu).toBeVisible(); + + // The caret is in the last block, so moving further down is what must be unavailable -- + // which is also what says the menu was built for that block and not some default. + await expect(menu.getByRole('menuitem', { name: 'Move down' })).toHaveAttribute('aria-disabled', 'true'); + await expect(menu.getByRole('menuitem', { name: 'Move up' })).not.toHaveAttribute('aria-disabled', 'true'); + }); +}); + +test.describe('presentation', () => { + test('highlights the whole row on hover, not just the label', async ({ page }) => { + await page.getByRole('textbox', { name: 'Paragraph' }).first() + .hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + + await expect(page.getByRole('menu', { name: 'Block settings' })).toBeVisible(); + + // Regression test for the item wrapper: rendering menu items as `button` — right for + // ui-kit's inline toolbar, wrong for a vertical menu — made each row shrink to its own + // label, so the hover highlight covered a ragged part of it. Measured in one pass in the + // page and after the webfont has settled, because one locator at a time reads the rows at + // different moments and a loading font moves the menu's width between them. + const widths = await page.evaluate(async () => { + await document.fonts.ready; + + // Scoped by name: the toolbox renders a `role="menu"` of its own, closed but present. + const menu = document.querySelector('[role="menu"][aria-label="Block settings"]'); + + return [...(menu?.querySelectorAll('[role="menuitem"]') ?? [])] + .map(item => item.getBoundingClientRect().width); + }); + + expect(widths).toHaveLength(DEFAULT_ITEM_COUNT); + expect(widths[0]).toBeGreaterThan(0); + + // Every row is as wide as the menu rather than as wide as its own label. Broken, these came + // out at roughly 90 / 108 / 76 px — one per label length. + expect(new Set(widths).size).toBe(1); + }); + + test('disables moving further at the document boundaries', async ({ page }) => { + await page.getByRole('textbox', { name: 'Paragraph' }).first() + .hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + + const menu = page.getByRole('menu', { name: 'Block settings' }); + + await expect(menu.getByRole('menuitem', { name: 'Move up' })).toBeDisabled(); + await expect(menu.getByRole('menuitem', { name: 'Move down' })).toBeEnabled(); + }); + + test('marks an unavailable action as disabled rather than hiding it', async ({ page }) => { + await page.getByRole('textbox', { name: 'Paragraph' }).first() + .hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + + // Exposed through aria-disabled, and still reachable by the arrow keys: the APG keeps + // disabled menu items focusable so they can be discovered rather than silently missing. + const moveUp = page.getByRole('menu', { name: 'Block settings' }).getByRole('menuitem', { name: 'Move up' }); + + await expect(moveUp).toHaveAttribute('aria-disabled', 'true'); + }); +}); + +test.describe('keyboard operation', () => { + test('is operable from the keyboard alone', async ({ page }) => { + const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + + await blocks.first().hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + + const menu = page.getByRole('menu', { name: 'Block settings' }); + + await expect(menu).toBeVisible(); + + // The items are divs with role="menuitem", as the ARIA menu pattern calls for -- so nothing + // here is inherited from a native control. Arrow-key navigation, the roving tabindex and + // Enter activation are all ui-kit's, and this is what says they still hold for this menu. + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + + await expect(menu.getByRole('menuitem', { name: 'Move down' })).toBeFocused(); + + await page.keyboard.press('Enter'); + + await expect(blocks).toHaveText(['Beta', 'Alpha']); + await expect(menu).toBeHidden(); + }); + + test('is dismissed from its own button by a second Enter', async ({ page }) => { + const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + + await blocks.first().hover(); + + const button = page.getByRole('button', { name: 'Block settings' }); + + await button.click(); + + const menu = page.getByRole('menu', { name: 'Block settings' }); + + await expect(menu).toBeVisible(); + await expect(button).toBeFocused(); + + // ui-kit's flipper claims this Enter for the open menu and prevents the default before the + // event reaches the button, so no click is produced and the plugin has to dismiss the menu + // from the keydown itself. Without that, the menu cannot be closed from the button at all. + await page.keyboard.press('Enter'); + + await expect(menu).toBeHidden(); + await expect(button).toHaveAttribute('aria-expanded', 'false'); + }); +}); + +test.describe('actions', () => { + test('moves a block up from the settings menu', async ({ page }) => { + const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + + await expect(blocks).toHaveText(['Alpha', 'Beta']); + + await blocks.nth(1).hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + await page.getByRole('menuitem', { name: 'Move up' }).click(); + + await expect(blocks).toHaveText(['Beta', 'Alpha']); + }); + + test('deletes a block only after its confirmation is activated', async ({ page }) => { + const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + + await blocks.nth(1).hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + + // The item becomes its own confirmation in place: same element, new title, new action. + await expect(blocks).toHaveText(['Alpha', 'Beta']); + + await page.getByRole('menuitem', { name: 'Click to delete' }).click(); + + await expect(blocks).toHaveText(['Alpha']); + }); + + test('abandons a pending deletion when the menu is dismissed', async ({ page }) => { + const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + + await blocks.nth(1).hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.keyboard.press('Escape'); + + await expect(blocks).toHaveText(['Alpha', 'Beta']); + }); +}); diff --git a/packages/editorjs/package.json b/packages/editorjs/package.json index d467e522..e963ba58 100644 --- a/packages/editorjs/package.json +++ b/packages/editorjs/package.json @@ -8,7 +8,7 @@ "type": "module", "scripts": { "build": "yarn build:dependencies && yarn clear && tsc --build tsconfig.build.json", - "build:dependencies": "yarn workspaces foreach -Rpt --from $npm_package_name --exclude $npm_package_name run build", + "build:dependencies": "yarn workspaces foreach -Rp --topological-dev --from $npm_package_name --exclude $npm_package_name run build", "build:declaration": "yarn build --emitDeclarationOnly", "lint": "eslint ./src ./e2e playwright.config.ts playwright.voiceover.config.ts", "lint:ci": "yarn lint --max-warnings 0", @@ -47,6 +47,7 @@ "@editorjs/clipboard-plugin": "workspace:^", "@editorjs/collaboration-manager": "workspace:^", "@editorjs/core": "workspace:^", + "@editorjs/default-block-settings-plugin": "workspace:^", "@editorjs/dom-adapters": "workspace:^", "@editorjs/inline-link": "workspace:^", "@editorjs/italic": "workspace:^", diff --git a/packages/editorjs/src/index.spec.ts b/packages/editorjs/src/index.spec.ts index 5e3166e4..4595b2b2 100644 --- a/packages/editorjs/src/index.spec.ts +++ b/packages/editorjs/src/index.spec.ts @@ -27,12 +27,23 @@ jest.unstable_mockModule('@editorjs/italic', () => ({ ItalicInlineTool: class It jest.unstable_mockModule('@editorjs/inline-link', () => ({ LinkInlineTool: class LinkInlineTool {} })); jest.unstable_mockModule('@editorjs/clipboard-plugin', () => ({ ClipboardPlugin: class ClipboardPlugin {} })); jest.unstable_mockModule('@editorjs/shortcuts-plugin', () => ({ ShortcutsPlugin: class ShortcutsPlugin {} })); +jest.unstable_mockModule('@editorjs/default-block-settings-plugin', () => ({ + /** + * The assertions below read `ctor.name`, and this plugin declares a static `name`, so + * what they see is its plugin id rather than the class name. The mock declares it too, + * otherwise the test would assert a name the real module never reports + */ + DefaultBlockSettingsPlugin: class DefaultBlockSettingsPlugin { + public static readonly name = 'default-block-settings'; + }, +})); jest.unstable_mockModule('@editorjs/ui', () => ({ EditorjsUI: class EditorjsUI {}, BlocksUI: class BlocksUI {}, InlineToolbarUI: class InlineToolbarUI {}, ToolbarUI: class ToolbarUI {}, ToolboxUI: class ToolboxUI {}, + BlockSettingsUI: class BlockSettingsUI {}, })); const { default: EditorJS } = await import('./index.js'); @@ -78,6 +89,20 @@ describe('EditorJS bundle', () => { expect(registered).toContain('ClipboardPlugin'); expect(registered).toContain('ShortcutsPlugin'); + expect(registered).toContain('default-block-settings'); + }); + + it('registers the default UI packages on the underlying Core', () => { + initialize.mockResolvedValue(undefined); + + void new EditorJS({} as any); + + const registered = use.mock.calls.map(([ctor]) => (ctor as { name: string }).name); + + expect(registered).toContain('EditorjsUI'); + expect(registered).toContain('ToolbarUI'); + expect(registered).toContain('ToolboxUI'); + expect(registered).toContain('BlockSettingsUI'); }); it('configures a user tool with the options it was registered with', () => { diff --git a/packages/editorjs/src/index.ts b/packages/editorjs/src/index.ts index 67281ee2..b94aa32e 100644 --- a/packages/editorjs/src/index.ts +++ b/packages/editorjs/src/index.ts @@ -9,7 +9,8 @@ import { ItalicInlineTool } from '@editorjs/italic'; import { LinkInlineTool } from '@editorjs/inline-link'; import { ClipboardPlugin } from '@editorjs/clipboard-plugin'; import { ShortcutsPlugin } from '@editorjs/shortcuts-plugin'; -import { EditorjsUI, BlocksUI, InlineToolbarUI, ToolbarUI, ToolboxUI } from '@editorjs/ui'; +import { DefaultBlockSettingsPlugin } from '@editorjs/default-block-settings-plugin'; +import { EditorjsUI, BlocksUI, BlockSettingsUI, InlineToolbarUI, ToolbarUI, ToolboxUI } from '@editorjs/ui'; import { mergeTools } from './mergeTools.js'; /** @@ -76,6 +77,7 @@ export default class EditorJS { */ this.#core.use(ClipboardPlugin); this.#core.use(ShortcutsPlugin); + this.#core.use(DefaultBlockSettingsPlugin); /** * Default tools merged with user-provided `config.tools` (user wins by name). @@ -92,6 +94,7 @@ export default class EditorJS { this.#core.use(InlineToolbarUI); this.#core.use(ToolbarUI); this.#core.use(ToolboxUI); + this.#core.use(BlockSettingsUI); this.#readyPromise = this.#core.initialize(); } diff --git a/packages/model-types/src/BlockNode.ts b/packages/model-types/src/BlockNode.ts index d4fcf069..dac85081 100644 --- a/packages/model-types/src/BlockNode.ts +++ b/packages/model-types/src/BlockNode.ts @@ -1,6 +1,6 @@ import type { TextNodeSerialized } from './Text.js'; import type { ValueSerialized } from './Value.js'; -import type { BlockTuneSerialized } from './BlockTune.js'; +import type { PluginDataSerialized } from './PluginData.js'; // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents export type BlockChildNodeSerialized = ValueSerialized | TextNodeSerialized; @@ -13,7 +13,7 @@ export interface BlockNodeDataSerialized { [key: string]: BlockNodeDataSerializedValue; } -/** Describes a block with its id, tool name, data and optional tunes */ +/** Describes a block with its id, tool name, data and optional per-plugin data */ export interface BlockData { /** Block identifier */ id: string; @@ -21,8 +21,8 @@ export interface BlockData { name: string; /** Block content data */ data: BlockNodeDataSerialized; - /** Optional block tune data */ - tunes?: Record; + /** Optional per-plugin data, keyed by plugin name */ + plugins?: Record; } /** Minimum data required to initialise a block */ diff --git a/packages/model-types/src/BlockTune.ts b/packages/model-types/src/BlockTune.ts deleted file mode 100644 index 0da8c3db..00000000 --- a/packages/model-types/src/BlockTune.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Nominal } from './Nominal.js'; -import { create } from './Nominal.js'; - -/** Name of a block tune */ -export type BlockTuneName = Nominal; - -/** Factory for BlockTuneName */ -export const createBlockTuneName = create(); - -/** Serialized data for a block tune */ -export interface BlockTuneSerialized { - [key: string]: unknown; -} diff --git a/packages/model-types/src/EventMap.ts b/packages/model-types/src/EventMap.ts index 095fdf0b..e7ebef8a 100644 --- a/packages/model-types/src/EventMap.ts +++ b/packages/model-types/src/EventMap.ts @@ -5,7 +5,7 @@ import type { TextRemovedEvent } from './events/TextRemovedEvent.js'; import type { TextFormattedEvent } from './events/TextFormattedEvent.js'; import type { TextUnformattedEvent } from './events/TextUnformattedEvent.js'; import type { ValueModifiedEvent } from './events/ValueModifiedEvent.js'; -import type { TuneModifiedEvent } from './events/TuneModifiedEvent.js'; +import type { PluginDataModifiedEvent } from './events/PluginDataModifiedEvent.js'; import type { PropertyModifiedEvent } from './events/PropertyModifiedEvent.js'; import type { CaretManagerCaretAddedEvent } from './events/CaretManagerCaretAddedEvent.js'; import type { CaretManagerCaretRemovedEvent } from './events/CaretManagerCaretRemovedEvent.js'; @@ -27,9 +27,9 @@ export type TextNodeEvents = TextAddedEvent | TextRemovedEvent | TextFormattedEv export type ValueNodeEvents = ValueModifiedEvent; /** - * Alias for all block tune events + * Alias for all plugin data events */ -export type BlockTuneEvents = TuneModifiedEvent; +export type PluginDataEvents = PluginDataModifiedEvent; /** * Alias for all document-level events @@ -39,7 +39,7 @@ export type DocumentEvents = PropertyModifiedEvent; /** * Union of all events that can be emitted by the document model */ -export type ModelEvents = BlockEvents | TextNodeEvents | ValueNodeEvents | BlockTuneEvents | DocumentEvents; +export type ModelEvents = BlockEvents | TextNodeEvents | ValueNodeEvents | PluginDataEvents | DocumentEvents; /** * Union of all events that can be emitted by the caret manager diff --git a/packages/model-types/src/Index/Index.spec.ts b/packages/model-types/src/Index/Index.spec.ts index 3aed9f19..49104717 100644 --- a/packages/model-types/src/Index/Index.spec.ts +++ b/packages/model-types/src/Index/Index.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ -import type { DataKey, BlockTuneName, DocumentId } from '@editorjs/model-types'; +import type { DataKey, PluginDataName, DocumentId } from '@editorjs/model-types'; import { Index, IndexKind, @@ -8,7 +8,8 @@ import { DocumentIndex, PropertyIndex, TextIndex, - TuneIndex + PluginDataIndex, + PartialIndex } from './index.js'; describe('Index', () => { @@ -57,19 +58,19 @@ describe('Index', () => { }); }); - describe('Index.tune()', () => { - it('creates a TuneIndex with block, tune name, and tune key', () => { - const idx = Index.tune(3, 'header' as BlockTuneName, 'level'); + describe('Index.pluginData()', () => { + it('creates a PluginDataIndex with block, plugin name, and plugin key', () => { + const idx = Index.pluginData(3, 'anchors' as PluginDataName, 'id'); - expect(idx).toBeInstanceOf(TuneIndex); - expect(idx.kind).toBe(IndexKind.Tune); + expect(idx).toBeInstanceOf(PluginDataIndex); + expect(idx.kind).toBe(IndexKind.PluginData); expect(idx.blockIndex).toBe(3); - expect(idx.tuneName).toBe('header'); - expect(idx.tuneKey).toBe('level'); + expect(idx.pluginName).toBe('anchors'); + expect(idx.pluginKey).toBe('id'); }); it('optionally accepts documentId', () => { - const idx = Index.tune(3, 'header' as BlockTuneName, 'level', 'doc1' as DocumentId); + const idx = Index.pluginData(3, 'anchors' as PluginDataName, 'id', 'doc1' as DocumentId); expect(idx.documentId).toBe('doc1'); }); @@ -137,8 +138,8 @@ describe('Index', () => { expect(Index.data(0, 'key' as DataKey).kind).not.toBe(IndexKind.Block); }); - it('TuneIndex kind is not Block', () => { - expect(Index.tune(0, 'tune' as BlockTuneName, 'key').kind).not.toBe(IndexKind.Block); + it('PluginDataIndex kind is not Block', () => { + expect(Index.pluginData(0, 'anchors' as PluginDataName, 'key').kind).not.toBe(IndexKind.Block); }); }); @@ -389,13 +390,13 @@ describe('Index', () => { expect(parsed.documentId).toBe('doc1'); }); - it('round-trips a TuneIndex', () => { - const idx = Index.tune(3, 'header' as BlockTuneName, 'level'); - const parsed = Index.parse(idx.serialize()) as TuneIndex; + it('round-trips a PluginDataIndex', () => { + const idx = Index.pluginData(3, 'anchors' as PluginDataName, 'id'); + const parsed = Index.parse(idx.serialize()) as PluginDataIndex; expect(parsed.blockIndex).toBe(3); - expect(parsed.tuneName).toBe('header'); - expect(parsed.tuneKey).toBe('level'); + expect(parsed.pluginName).toBe('anchors'); + expect(parsed.pluginKey).toBe('id'); }); it('round-trips a DataIndex', () => { @@ -443,11 +444,11 @@ describe('Index', () => { id: 'doc1' }); }); - it('emits the expected wire object for TuneIndex', () => { - expect(JSON.parse(Index.tune(3, 'header' as BlockTuneName, 'level').serialize())).toEqual({ k: 'tune', + it('emits the expected wire object for PluginDataIndex', () => { + expect(JSON.parse(Index.pluginData(3, 'anchors' as PluginDataName, 'id').serialize())).toEqual({ k: 'plugin', b: 3, - tune: 'header', - key: 'level' }); + plugin: 'anchors', + key: 'id' }); }); it('emits the expected wire object for DataIndex', () => { @@ -504,4 +505,38 @@ describe('Index', () => { expect(() => Index.parse('{"k":"unknown"}')).toThrow('Unknown index kind'); }); }); + + describe('PartialIndex#resolve() for plugin data', () => { + it('should resolve to a PluginDataIndex once block index is attached', () => { + const resolved = new PartialIndex({ pluginName: 'anchors' as PluginDataName, + pluginKey: 'id' }) + .withBlockIndex(2) + .resolve(); + + expect(resolved).toBeInstanceOf(PluginDataIndex); + expect((resolved as PluginDataIndex).pluginName).toBe('anchors'); + expect((resolved as PluginDataIndex).pluginKey).toBe('id'); + expect((resolved as PluginDataIndex).blockIndex).toBe(2); + }); + + it('should throw when the plugin key is missing', () => { + expect(() => new PartialIndex({ pluginName: 'anchors' as PluginDataName }) + .withBlockIndex(2) + .resolve()).toThrow('PluginDataIndex requires pluginKey'); + }); + + it('should throw when the plugin name is missing', () => { + expect(() => new PartialIndex({ pluginKey: 'id' }) + .withBlockIndex(2) + .resolve()).toThrow('PluginDataIndex requires pluginName'); + }); + + it('should throw when combined with a data key', () => { + expect(() => new PartialIndex({ dataKey: 'text' as DataKey, + pluginName: 'anchors' as PluginDataName, + pluginKey: 'id' }) + .withBlockIndex(2) + .resolve()).toThrow('DataIndex cannot be combined with pluginName'); + }); + }); }); diff --git a/packages/model-types/src/Index/IndexBase.ts b/packages/model-types/src/Index/IndexBase.ts index fda548a6..a329a745 100644 --- a/packages/model-types/src/Index/IndexBase.ts +++ b/packages/model-types/src/Index/IndexBase.ts @@ -1,4 +1,4 @@ -import type { BlockTuneName } from '../BlockTune.js'; +import type { PluginDataName } from '../PluginData.js'; import type { DataKey } from '../DataKey.js'; import type { DocumentId } from '../indexing.js'; import type { TextRange } from '../Text.js'; @@ -13,8 +13,8 @@ export enum IndexKind { Property = 'property', /** Block scope */ Block = 'block', - /** Block tune scope */ - Tune = 'tune', + /** Plugin per-block data scope */ + PluginData = 'plugin', /** Block data-key scope */ Data = 'data', /** Inline text scope (single or composite) */ @@ -43,10 +43,10 @@ export interface IndexFields { textRange?: TextRange; /** Data key within the block */ dataKey?: DataKey; - /** Tune name within the block */ - tuneName?: BlockTuneName; - /** Tune key within the block tune */ - tuneKey?: string; + /** Plugin name within the block */ + pluginName?: PluginDataName; + /** Key within the plugin's per-block data */ + pluginKey?: string; /** Block index within the document */ blockIndex?: number; /** Property name within the document */ diff --git a/packages/model-types/src/Index/PartialIndex.ts b/packages/model-types/src/Index/PartialIndex.ts index 9acfede2..273d62b4 100644 --- a/packages/model-types/src/Index/PartialIndex.ts +++ b/packages/model-types/src/Index/PartialIndex.ts @@ -1,4 +1,4 @@ -import type { BlockTuneName } from '../BlockTune.js'; +import type { PluginDataName } from '../PluginData.js'; import type { DataKey } from '../DataKey.js'; import type { DocumentId } from '../indexing.js'; import type { TextRange } from '../Text.js'; @@ -8,7 +8,7 @@ import { DataIndex } from './DataIndex.js'; import { DocumentIndex } from './DocumentIndex.js'; import { PropertyIndex } from './PropertyIndex.js'; import { TextIndex } from './TextIndex.js'; -import { TuneIndex } from './TuneIndex.js'; +import { PluginDataIndex } from './PluginDataIndex.js'; /** * Internal placeholder used during event bubbling. @@ -41,17 +41,17 @@ export class PartialIndex extends IndexBase { } /** - * Tune name if set + * Plugin data name if set */ - public get tuneName(): BlockTuneName | undefined { - return this.#fields.tuneName; + public get pluginName(): PluginDataName | undefined { + return this.#fields.pluginName; } /** - * Tune key if set + * Plugin data key if set */ - public get tuneKey(): string | undefined { - return this.#fields.tuneKey; + public get pluginKey(): string | undefined { + return this.#fields.pluginKey; } /** @@ -89,7 +89,7 @@ export class PartialIndex extends IndexBase { */ public get isBlockIndex(): boolean { return this.#fields.blockIndex !== undefined - && this.#fields.tuneName === undefined + && this.#fields.pluginName === undefined && this.#fields.dataKey === undefined && this.#fields.textRange === undefined; } @@ -99,7 +99,7 @@ export class PartialIndex extends IndexBase { */ public get isDataIndex(): boolean { return this.#fields.blockIndex !== undefined - && this.#fields.tuneName === undefined + && this.#fields.pluginName === undefined && this.#fields.dataKey !== undefined && this.#fields.textRange === undefined; } @@ -142,8 +142,8 @@ export class PartialIndex extends IndexBase { const { textRange, dataKey, - tuneName, - tuneKey, + pluginName, + pluginKey, blockIndex, propertyName, documentId, @@ -158,7 +158,7 @@ export class PartialIndex extends IndexBase { if ( blockIndex !== undefined || dataKey !== undefined - || tuneName !== undefined + || pluginName !== undefined || textRange !== undefined ) { throw new Error( @@ -188,8 +188,8 @@ export class PartialIndex extends IndexBase { } if (dataKey !== undefined) { - if (tuneName !== undefined) { - throw new Error('DataIndex cannot be combined with tuneName'); + if (pluginName !== undefined) { + throw new Error('DataIndex cannot be combined with pluginName'); } if (blockIndex === undefined) { throw new Error('DataIndex requires blockIndex'); @@ -198,18 +198,18 @@ export class PartialIndex extends IndexBase { return new DataIndex(blockIndex, dataKey, documentId); } - if (tuneName !== undefined || tuneKey !== undefined) { + if (pluginName !== undefined || pluginKey !== undefined) { if (blockIndex === undefined) { - throw new Error('TuneIndex requires blockIndex'); + throw new Error('PluginDataIndex requires blockIndex'); } - if (tuneName === undefined || tuneName.length === 0) { - throw new Error('TuneIndex requires tuneName'); + if (pluginName === undefined || pluginName.length === 0) { + throw new Error('PluginDataIndex requires pluginName'); } - if (tuneKey === undefined) { - throw new Error('TuneIndex requires tuneKey'); + if (pluginKey === undefined) { + throw new Error('PluginDataIndex requires pluginKey'); } - return new TuneIndex(blockIndex, tuneName, tuneKey, documentId); + return new PluginDataIndex(blockIndex, pluginName, pluginKey, documentId); } if (blockIndex !== undefined) { diff --git a/packages/model-types/src/Index/PluginDataIndex.ts b/packages/model-types/src/Index/PluginDataIndex.ts new file mode 100644 index 00000000..e608f364 --- /dev/null +++ b/packages/model-types/src/Index/PluginDataIndex.ts @@ -0,0 +1,99 @@ +import type { PluginDataName } from '../PluginData.js'; +import type { DocumentId } from '../indexing.js'; +import { IndexBase, IndexKind } from './IndexBase.js'; + +/** + * Index scoped to one key of one plugin's per-block data + */ +export class PluginDataIndex extends IndexBase { + readonly #blockIndex: number; + readonly #pluginName: PluginDataName; + readonly #pluginKey: string; + readonly #documentId?: DocumentId; + + /** + * @param blockIndex - zero-based block position + * @param pluginName - name of the plugin owning the data + * @param pluginKey - key within the plugin's data + * @param documentId - optional document identifier + */ + constructor( + blockIndex: number, + pluginName: PluginDataName, + pluginKey: string, + documentId?: DocumentId + ) { + super(IndexKind.PluginData); + this.#blockIndex = blockIndex; + this.#pluginName = pluginName; + this.#pluginKey = pluginKey; + this.#documentId = documentId; + } + + /** + * The zero-based position of the indexed block + */ + public get blockIndex(): number { + return this.#blockIndex; + } + + /** + * The name of the plugin owning the indexed data + */ + public get pluginName(): PluginDataName { + return this.#pluginName; + } + + /** + * The key within the plugin's data + */ + public get pluginKey(): string { + return this.#pluginKey; + } + + /** + * The unique identifier of the indexed document + */ + public override get documentId(): DocumentId | undefined { + return this.#documentId; + } + + /** + * @param blockIndex - updated block position + */ + public override withBlockIndex(blockIndex: number): PluginDataIndex { + return new PluginDataIndex(blockIndex, this.#pluginName, this.#pluginKey, this.#documentId); + } + + /** + * @param documentId - updated document identifier + */ + public override withDocumentId(documentId: DocumentId): PluginDataIndex { + return new PluginDataIndex(this.#blockIndex, this.#pluginName, this.#pluginKey, documentId); + } + + /** + * Creates a deep copy + */ + public clone(): PluginDataIndex { + return new PluginDataIndex( + this.#blockIndex, + this.#pluginName, + this.#pluginKey, + this.#documentId + ); + } + + /** + * Serializes to JSON + */ + public serialize(): string { + return JSON.stringify({ + k: 'plugin', + b: this.#blockIndex, + plugin: this.#pluginName, + key: this.#pluginKey, + ...(this.#documentId !== undefined && { id: this.#documentId }), + }); + } +} diff --git a/packages/model-types/src/Index/TuneIndex.ts b/packages/model-types/src/Index/TuneIndex.ts deleted file mode 100644 index e9b0fbf3..00000000 --- a/packages/model-types/src/Index/TuneIndex.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { BlockTuneName } from '../BlockTune.js'; -import type { DocumentId } from '../indexing.js'; -import { IndexBase, IndexKind } from './IndexBase.js'; - -/** - * Index scoped to a block tune - */ -export class TuneIndex extends IndexBase { - readonly #blockIndex: number; - readonly #tuneName: BlockTuneName; - readonly #tuneKey: string; - readonly #documentId?: DocumentId; - - /** - * @param blockIndex - zero-based block position - * @param tuneName - name of the block tune - * @param tuneKey - key within the block tune - * @param documentId - optional document identifier - */ - constructor( - blockIndex: number, - tuneName: BlockTuneName, - tuneKey: string, - documentId?: DocumentId - ) { - super(IndexKind.Tune); - this.#blockIndex = blockIndex; - this.#tuneName = tuneName; - this.#tuneKey = tuneKey; - this.#documentId = documentId; - } - - /** - * The zero-based position of the indexed block - */ - public get blockIndex(): number { - return this.#blockIndex; - } - - /** - * The name of the indexed block tune - */ - public get tuneName(): BlockTuneName { - return this.#tuneName; - } - - /** - * The key within the indexed block tune - */ - public get tuneKey(): string { - return this.#tuneKey; - } - - /** - * The unique identifier of the indexed document - */ - public override get documentId(): DocumentId | undefined { - return this.#documentId; - } - - /** - * @param blockIndex - updated block position - */ - public override withBlockIndex(blockIndex: number): TuneIndex { - return new TuneIndex(blockIndex, this.#tuneName, this.#tuneKey, this.#documentId); - } - - /** - * @param documentId - updated document identifier - */ - public override withDocumentId(documentId: DocumentId): TuneIndex { - return new TuneIndex(this.#blockIndex, this.#tuneName, this.#tuneKey, documentId); - } - - /** - * Creates a deep copy - */ - public clone(): TuneIndex { - return new TuneIndex( - this.#blockIndex, - this.#tuneName, - this.#tuneKey, - this.#documentId - ); - } - - /** - * Serializes to JSON - */ - public serialize(): string { - return JSON.stringify({ - k: 'tune', - b: this.#blockIndex, - tune: this.#tuneName, - key: this.#tuneKey, - ...(this.#documentId !== undefined && { id: this.#documentId }), - }); - } -} diff --git a/packages/model-types/src/Index/index.ts b/packages/model-types/src/Index/index.ts index 30650d15..de70a166 100644 --- a/packages/model-types/src/Index/index.ts +++ b/packages/model-types/src/Index/index.ts @@ -2,20 +2,20 @@ export { IndexBase, IndexKind, type IndexFields, type TextSegment } from './Inde export { DocumentIndex } from './DocumentIndex.js'; export { PropertyIndex } from './PropertyIndex.js'; export { BlockIndex } from './BlockIndex.js'; -export { TuneIndex } from './TuneIndex.js'; +export { PluginDataIndex } from './PluginDataIndex.js'; export { DataIndex } from './DataIndex.js'; export { TextIndex } from './TextIndex.js'; export { PartialIndex } from './PartialIndex.js'; import { IndexBase, IndexKind, type TextSegment } from './IndexBase.js'; import type { DocumentId } from '../indexing.js'; -import type { BlockTuneName } from '../BlockTune.js'; +import type { PluginDataName } from '../PluginData.js'; import type { DataKey } from '../DataKey.js'; import type { TextRange } from '../Text.js'; import { DocumentIndex } from './DocumentIndex.js'; import { PropertyIndex } from './PropertyIndex.js'; import { BlockIndex } from './BlockIndex.js'; -import { TuneIndex } from './TuneIndex.js'; +import { PluginDataIndex } from './PluginDataIndex.js'; import { DataIndex } from './DataIndex.js'; import { TextIndex } from './TextIndex.js'; @@ -106,19 +106,19 @@ export abstract class Index extends IndexBase { } /** - * Creates a TuneIndex + * Creates a PluginDataIndex * @param blockIndex - zero-based block position - * @param tuneName - name of the block tune - * @param tuneKey - key within the block tune + * @param pluginName - name of the plugin owning the data + * @param pluginKey - key within the plugin's data * @param documentId - optional document identifier */ - public static tune( + public static pluginData( blockIndex: number, - tuneName: BlockTuneName, - tuneKey: string, + pluginName: PluginDataName, + pluginKey: string, documentId?: DocumentId - ): TuneIndex { - return new TuneIndex(blockIndex, tuneName, tuneKey, documentId); + ): PluginDataIndex { + return new PluginDataIndex(blockIndex, pluginName, pluginKey, documentId); } /** @@ -172,10 +172,10 @@ export abstract class Index extends IndexBase { obj.id as DocumentId | undefined ); - case 'tune': - return new TuneIndex( + case 'plugin': + return new PluginDataIndex( obj.b as number, - obj.tune as BlockTuneName, + obj.plugin as PluginDataName, obj.key as string, obj.id as DocumentId | undefined ); diff --git a/packages/model-types/src/PluginData.ts b/packages/model-types/src/PluginData.ts new file mode 100644 index 00000000..ba1c95ac --- /dev/null +++ b/packages/model-types/src/PluginData.ts @@ -0,0 +1,13 @@ +import type { Nominal } from './Nominal.js'; +import { create } from './Nominal.js'; + +/** Name a plugin's per-block data is stored under, by convention the plugin's `name` */ +export type PluginDataName = Nominal; + +/** Factory for PluginDataName */ +export const createPluginDataName = create(); + +/** Serialized per-block data of a single plugin */ +export interface PluginDataSerialized { + [key: string]: unknown; +} diff --git a/packages/model-types/src/events/PluginDataModifiedEvent.ts b/packages/model-types/src/events/PluginDataModifiedEvent.ts new file mode 100644 index 00000000..9f2362dd --- /dev/null +++ b/packages/model-types/src/events/PluginDataModifiedEvent.ts @@ -0,0 +1,20 @@ +import { EventAction } from '../EventAction.js'; +import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; +import type { PluginDataIndex } from '../Index/PluginDataIndex.js'; +import type { PartialIndex } from '../Index/PartialIndex.js'; +import type { ModifiedEventData } from '../EventBus.js'; + +/** + * PluginDataModified Custom Event + */ +export class PluginDataModifiedEvent extends BaseDocumentEvent, PluginDataIndex> { + /** + * PluginDataModifiedEvent class constructor + * @param index - index of the modified plugin data key in the document + * @param data - event data with new and previous values + * @param userId - user identifier + */ + constructor(index: PluginDataIndex | PartialIndex, data: ModifiedEventData, userId: string | number) { + super(index, EventAction.Modified, data, userId); + } +} diff --git a/packages/model-types/src/events/TuneModifiedEvent.ts b/packages/model-types/src/events/TuneModifiedEvent.ts deleted file mode 100644 index 0e8b4e08..00000000 --- a/packages/model-types/src/events/TuneModifiedEvent.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { EventAction } from '../EventAction.js'; -import { BaseDocumentEvent } from '../BaseDocumentEvent.js'; -import type { TuneIndex } from '../Index/TuneIndex.js'; -import type { PartialIndex } from '../Index/PartialIndex.js'; -import type { ModifiedEventData } from '../EventBus.js'; - -/** - * TuneModified Custom Event - */ -export class TuneModifiedEvent extends BaseDocumentEvent, TuneIndex> { - /** - * TuneModifiedEvent class constructor - * @param index - index of the modified tune in the document - * @param data - event data with new and previous values - * @param userId - user identifier - */ - constructor(index: TuneIndex | PartialIndex, data: ModifiedEventData, userId: string | number) { - super(index, EventAction.Modified, data, userId); - } -} diff --git a/packages/model-types/src/events/index.ts b/packages/model-types/src/events/index.ts index a234ce00..db2493e4 100644 --- a/packages/model-types/src/events/index.ts +++ b/packages/model-types/src/events/index.ts @@ -11,4 +11,4 @@ export { CaretManagerCaretRemovedEvent } from './CaretManagerCaretRemovedEvent.j export { BlockAddedEvent } from './BlockAddedEvent.js'; export { BlockRemovedEvent } from './BlockRemovedEvent.js'; export { PropertyModifiedEvent } from './PropertyModifiedEvent.js'; -export { TuneModifiedEvent } from './TuneModifiedEvent.js'; +export { PluginDataModifiedEvent } from './PluginDataModifiedEvent.js'; diff --git a/packages/model-types/src/index.ts b/packages/model-types/src/index.ts index 84c7b4e6..4bfe30c2 100644 --- a/packages/model-types/src/index.ts +++ b/packages/model-types/src/index.ts @@ -11,10 +11,10 @@ export { createBlockToolName } from './BlockId.js'; export type { - BlockTuneName, - BlockTuneSerialized -} from './BlockTune.js'; -export { createBlockTuneName } from './BlockTune.js'; + PluginDataName, + PluginDataSerialized +} from './PluginData.js'; +export { createPluginDataName } from './PluginData.js'; export type { DataKey } from './DataKey.js'; export { createDataKey } from './DataKey.js'; export type { @@ -66,7 +66,7 @@ export type { BlockEvents, TextNodeEvents, ValueNodeEvents, - BlockTuneEvents, + PluginDataEvents, DocumentEvents, ModelEvents, CaretManagerEvents @@ -86,7 +86,7 @@ export { DocumentIndex, PropertyIndex, BlockIndex, - TuneIndex, + PluginDataIndex, DataIndex, TextIndex, PartialIndex, @@ -113,5 +113,5 @@ export { BlockAddedEvent, BlockRemovedEvent, PropertyModifiedEvent, - TuneModifiedEvent + PluginDataModifiedEvent } from './events/index.js'; diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index 3f579420..32df4b82 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -18,7 +18,7 @@ describe('EditorJSModel', () => { 'insertData', 'removeData', 'modifyData', - 'updateTuneData', + 'updatePluginData', 'updateValue', 'removeBlock', 'clearBlocks', diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index 8b9d7521..89ca8f67 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -349,17 +349,17 @@ export class EditorJSModel extends EventBus { } /** - * Updates BlockTune data associated with the BlockNode at the specified index. + * Updates one plugin's per-block data on the BlockNode at the specified index. * @param _userId - user identifier which is being set to the context - * @param parameters - updateTuneData method parameters + * @param parameters - updatePluginData method parameters * @param parameters.blockIndex - The index of the BlockNode to update - * @param parameters.tuneName - The name of the BlockTune to update - * @param parameters.data - The data to update the BlockTune with + * @param parameters.pluginName - Name the data is stored under, by convention the plugin's `name` + * @param parameters.data - Keys to merge into the plugin's data * @throws Error if the index is out of bounds */ @WithContext - public updateTuneData(_userId: string | number, ...parameters: Parameters): ReturnType { - return this.#document.updateTuneData(...parameters); + public updatePluginData(_userId: string | number, ...parameters: Parameters): ReturnType { + return this.#document.updatePluginData(...parameters); } /** diff --git a/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts index 81eb624c..a6ce4ff0 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts @@ -1,9 +1,16 @@ -import { BlockChildType, createInlineToolName, createDataKey } from '@editorjs/model-types'; +import { BlockChildType, createInlineToolName, createDataKey, createPluginDataName, EventType, PluginDataModifiedEvent } from '@editorjs/model-types'; import type { InlineFragment } from '@editorjs/model-types'; import { BlockNode } from './index.js'; import { NODE_TYPE_HIDDEN_PROP } from '@editorjs/model-types'; import { ValueNode } from '../ValueNode/index.js'; +/** + * Plugin names that collide with Object.prototype members, kept in constants so the + * literals don't trip the naming-convention lint rules + */ +const PROTO_NAME = '__proto__'; +const TO_STRING_NAME = 'toString'; + describe('BlockNode integration tests', () => { it('should create ValueNode by primitive value', () => { const value = 'value'; @@ -237,4 +244,99 @@ describe('BlockNode integration tests', () => { .toEqual(initData.key); }); }); + + describe('.updatePluginData() creating entries', () => { + it('should create the entry when the block has no data for that plugin', () => { + const blockNode = new BlockNode({ name: 'blockNode' }); + + blockNode.updatePluginData(createPluginDataName('anchors'), { id: 'intro' }); + + expect(blockNode.serialized.plugins) + .toEqual({ anchors: { id: 'intro' } }); + }); + + it('should emit an event with undefined previous value for the first write', () => { + const blockNode = new BlockNode({ name: 'blockNode' }); + let event: PluginDataModifiedEvent | null = null; + + blockNode.addEventListener(EventType.Changed, (e: Event): void => { + event = e as PluginDataModifiedEvent; + }); + + blockNode.updatePluginData(createPluginDataName('anchors'), { id: 'intro' }); + + expect(event) + .toBeInstanceOf(PluginDataModifiedEvent); + expect(event) + .toHaveProperty('detail.data', { + value: 'intro', + previous: undefined, + }); + }); + + it('should emit one event per changed key', () => { + const blockNode = new BlockNode({ name: 'blockNode' }); + const handler = jest.fn(); + + blockNode.addEventListener(EventType.Changed, handler); + + blockNode.updatePluginData(createPluginDataName('anchors'), { id: 'intro', + visible: true }); + + expect(handler) + .toHaveBeenCalledTimes(2); + }); + + it('should keep other keys when one key is updated', () => { + const blockNode = new BlockNode({ name: 'blockNode' }); + const pluginName = createPluginDataName('anchors'); + + blockNode.updatePluginData(pluginName, { id: 'intro', + visible: true }); + blockNode.updatePluginData(pluginName, { visible: false }); + + expect(blockNode.serialized.plugins) + .toEqual({ anchors: { id: 'intro', + visible: false } }); + }); + + it('should drop an entry from serialization once its last key is removed', () => { + const blockNode = new BlockNode({ name: 'blockNode' }); + const pluginName = createPluginDataName('anchors'); + + blockNode.updatePluginData(pluginName, { id: 'intro' }); + blockNode.updatePluginData(pluginName, { id: undefined }); + + expect(blockNode.serialized.plugins) + .toEqual({}); + }); + + it('should store data for a plugin named like an object prototype member', () => { + const blockNode = new BlockNode({ name: 'blockNode' }); + + blockNode.updatePluginData(createPluginDataName(PROTO_NAME), { id: 'intro' }); + blockNode.updatePluginData(createPluginDataName(TO_STRING_NAME), { id: 'other' }); + + expect(blockNode.serialized.plugins) + .toEqual({ [PROTO_NAME]: { id: 'intro' }, + [TO_STRING_NAME]: { id: 'other' } }); + expect(Object.prototype) + .not.toHaveProperty('id'); + }); + + it('should round-trip a plugin named like an object prototype member through initialization', () => { + const blockNode = new BlockNode({ name: 'blockNode', + plugins: { [PROTO_NAME]: { id: 'intro' } } }); + + expect(blockNode.serialized.plugins) + .toEqual({ [PROTO_NAME]: { id: 'intro' } }); + }); + + it('should throw when the plugin data name is empty', () => { + const blockNode = new BlockNode({ name: 'blockNode' }); + + expect(() => blockNode.updatePluginData(createPluginDataName(''), { id: 'intro' })) + .toThrow('plugin data name must not be empty'); + }); + }); }); diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index 801ab728..680f1a35 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -1,12 +1,13 @@ import { EventAction } from '@editorjs/model-types'; import { Index } from '@editorjs/model-types'; import { PartialIndex } from '@editorjs/model-types'; -import { createDataKey, createBlockId, createBlockToolName, createBlockTuneName } from '@editorjs/model-types'; +import { createDataKey, createBlockId, createBlockToolName, createPluginDataName } from '@editorjs/model-types'; import { BlockNode } from './index.js'; import { NonExistingKeyError } from './errors/NonExistingKeyError.js'; -import type { BlockTuneName, BlockTuneSerialized } from '@editorjs/model-types'; -import { BlockTune } from '../BlockTune/index.js'; +import type { PluginDataName, PluginDataSerialized } from '@editorjs/model-types'; +import { PluginDataNode } from '../PluginDataNode/index.js'; +import { runWithContext } from '../../utils/Context.js'; import { ValueNode } from '../ValueNode/index.js'; import type { EditorDocument } from '../EditorDocument/index.js'; @@ -17,7 +18,7 @@ import type { InlineFragment, TextNodeSerialized, BlockNodeDataSerialized } from import { TextNode } from '../inline-fragments/index.js'; import type { BlockNodeData } from './types/index.js'; import { NODE_TYPE_HIDDEN_PROP } from '@editorjs/model-types'; -import { TextAddedEvent, TuneModifiedEvent, ValueModifiedEvent } from '@editorjs/model-types'; +import { TextAddedEvent, PluginDataModifiedEvent, ValueModifiedEvent } from '@editorjs/model-types'; import { EventType } from '@editorjs/model-types'; import { get } from '@editorjs/model-types'; import { AlreadyExistingKeyError } from './errors/AlreadyExistingKeyError.js'; @@ -29,17 +30,17 @@ const ValueNodeProto = ValueNode.prototype as unknown as { update: () => void; }; -jest.mock('../BlockTune'); +jest.mock('../PluginDataNode'); jest.mock('../inline-fragments/TextNode'); jest.mock('../ValueNode'); -const createBlockNodeWithData = (data: BlockNodeDataSerialized, tunes: Record = {}): BlockNode => { +const createBlockNodeWithData = (data: BlockNodeDataSerialized, plugins: Record = {}): BlockNode => { return new BlockNode({ name: createBlockToolName('header'), data, - tunes, + plugins, }); }; @@ -116,31 +117,31 @@ describe('BlockNode', () => { .toEqual(blockNodeName); }); - it('should call .serialized getter of all tunes associated with the BlockNode', () => { - const blockTunesNames = [ - 'align' as BlockTuneName, - 'font-size' as BlockTuneName, - 'font-weight' as BlockTuneName, + it('should call .serialized getter of all plugin data nodes associated with the BlockNode', () => { + const pluginNames = [ + 'align' as PluginDataName, + 'font-size' as PluginDataName, + 'font-weight' as PluginDataName, ]; - const blockTunes = blockTunesNames.reduce((acc, name) => ({ + const pluginsDataMap = pluginNames.reduce((acc, name) => ({ ...acc, [name]: {}, }), {}); - const spy = jest.spyOn(BlockTune.prototype, 'serialized', 'get'); + const spy = jest.spyOn(PluginDataNode.prototype, 'serialized', 'get'); const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), data: {}, parent: {} as EditorDocument, - tunes: blockTunes, + plugins: pluginsDataMap, }); blockNode.serialized; expect(spy) - .toHaveBeenCalledTimes(blockTunesNames.length); + .toHaveBeenCalledTimes(pluginNames.length); }); it('should call .serialized getter of all child ValueNodes associated with the BlockNode', () => { @@ -434,15 +435,15 @@ describe('BlockNode', () => { }); }); - describe('.tunes', () => { - it('should return an object with tunes associated with the BlockNode', () => { - const blockTunesNames = [ - 'align' as BlockTuneName, - 'font-size' as BlockTuneName, - 'font-weight' as BlockTuneName, + describe('.plugins', () => { + it('should return an object with plugin data nodes associated with the BlockNode', () => { + const pluginNames = [ + 'align' as PluginDataName, + 'font-size' as PluginDataName, + 'font-weight' as PluginDataName, ]; - const blockTunes = blockTunesNames.reduce((acc, name) => ({ + const pluginsDataMap = pluginNames.reduce((acc, name) => ({ ...acc, [name]: {}, }), {}); @@ -451,16 +452,16 @@ describe('BlockNode', () => { name: createBlockToolName('paragraph'), data: {}, parent: {} as EditorDocument, - tunes: blockTunes, + plugins: pluginsDataMap, }); - const tunes = Object.entries(blockNode.tunes); + const pluginsData = Object.entries(blockNode.plugins); - tunes.forEach(([name, tune]) => { + pluginsData.forEach(([name, pluginData]) => { expect(name) - .toEqual(createBlockTuneName(name)); - expect(tune) - .toBeInstanceOf(BlockTune); + .toEqual(createPluginDataName(name)); + expect(pluginData) + .toBeInstanceOf(PluginDataNode); }); }); }); @@ -853,20 +854,38 @@ describe('BlockNode', () => { }); }); - describe('.updateTuneData()', () => { + describe('constructing with plugin data', () => { afterEach(() => { jest.clearAllMocks(); }); - it('should call .update() method of the BlockTune', () => { - const blockTuneName = 'align' as BlockTuneName; + it('should apply every key of the initial plugin data through the node', () => { + const spy = jest.spyOn(PluginDataNode.prototype, 'update'); + + createBlockNodeWithData({}, { anchors: { id: 'intro', + visible: true } }); + + expect(spy) + .toHaveBeenCalledWith('id', 'intro'); + expect(spy) + .toHaveBeenCalledWith('visible', true); + }); + }); + + describe('.updatePluginData()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call .update() method of the PluginDataNode', () => { + const pluginDataName = 'align' as PluginDataName; const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), data: {}, parent: {} as EditorDocument, - tunes: { - [blockTuneName]: {}, + plugins: { + [pluginDataName]: {}, }, }); @@ -876,9 +895,9 @@ describe('BlockNode', () => { [dataKey]: dataValue, }; - const spy = jest.spyOn(BlockTune.prototype, 'update'); + const spy = jest.spyOn(PluginDataNode.prototype, 'update'); - blockNode.updateTuneData(blockTuneName, data); + blockNode.updatePluginData(pluginDataName, data); expect(spy) .toHaveBeenCalledWith(dataKey, dataValue); @@ -1703,7 +1722,7 @@ describe('BlockNode', () => { })); }); - it('should not emit Changed event if ValueNode dispatched event that is not a BaseDocumentEvent', () => { + it('should not emit Changed event if PluginDataNode dispatched event that is not a BaseDocumentEvent', () => { const handler = jest.fn(); node.addEventListener(EventType.Changed, handler); @@ -1716,10 +1735,10 @@ describe('BlockNode', () => { }); }); - describe('working with BlockTune events', () => { + describe('working with PluginDataNode events', () => { let node: BlockNode; - let tune: BlockTune; - const tuneName = createBlockTuneName('tune'); + let pluginData: PluginDataNode; + const pluginName = createPluginDataName('anchors'); const key = 'key'; const value = 'value'; const newValue = 'new-value'; @@ -1728,49 +1747,75 @@ describe('BlockNode', () => { node = createBlockNodeWithData( {}, { - [tuneName]: { [key]: value }, + [pluginName]: { [key]: value }, } ); - tune = node.tunes[tuneName]; + pluginData = node.plugins[pluginName]; }); - it('should re-emit event from the BlockTune adding index in Block', () => { - let event: TuneModifiedEvent | null = null; + it('should re-emit event from the PluginDataNode adding index in Block', () => { + let event: PluginDataModifiedEvent | null = null; const handler = (e: Event): void => { - event = e as TuneModifiedEvent; + event = e as PluginDataModifiedEvent; }; node.addEventListener(EventType.Changed, handler); - tune.dispatchEvent( - new TuneModifiedEvent( - new PartialIndex({ tuneKey: key }), - { - value: newValue, - previous: value, - }, - 'user' - ) - ); + runWithContext('editor-user', () => { + pluginData.dispatchEvent( + new PluginDataModifiedEvent( + new PartialIndex({ pluginKey: key }), + { + value: newValue, + previous: value, + }, + 'editor-user' + ) + ); + }); expect(event) - .toBeInstanceOf(TuneModifiedEvent); + .toBeInstanceOf(PluginDataModifiedEvent); expect(event) .toHaveProperty('detail.index', expect.objectContaining({ - tuneKey: key, - tuneName: tuneName, + pluginKey: key, + pluginName: pluginName, })); expect(event) - .toHaveProperty('detail.userId', 'user'); + .toHaveProperty('detail.userId', 'editor-user'); + }); + + it('should re-emit the event with the acting user from the context, not a placeholder', () => { + let event: PluginDataModifiedEvent | null = null; + + node.addEventListener(EventType.Changed, (e: Event): void => { + event = e as PluginDataModifiedEvent; + }); + + runWithContext('another-user', () => { + pluginData.dispatchEvent( + new PluginDataModifiedEvent( + new PartialIndex({ pluginKey: key }), + { + value: newValue, + previous: value, + }, + 'another-user' + ) + ); + }); + + expect(event) + .toHaveProperty('detail.userId', 'another-user'); }); - it('should not emit Changed event if ValueNode dispatched event that is not a BaseDocumentEvent', () => { + it('should not emit Changed event if PluginDataNode dispatched event that is not a BaseDocumentEvent', () => { const handler = jest.fn(); node.addEventListener(EventType.Changed, handler); - tune.dispatchEvent(new Event(EventType.Changed)); + pluginData.dispatchEvent(new Event(EventType.Changed)); expect(handler) .not diff --git a/packages/model/src/entities/BlockNode/__mocks__/index.ts b/packages/model/src/entities/BlockNode/__mocks__/index.ts index 1a62e21f..645017e0 100644 --- a/packages/model/src/entities/BlockNode/__mocks__/index.ts +++ b/packages/model/src/entities/BlockNode/__mocks__/index.ts @@ -62,7 +62,7 @@ export class BlockNode extends EventBus { /** * Mock method */ - public updateTuneData(): void { + public updatePluginData(): void { return; } diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index 47e434bb..44c08912 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -1,6 +1,6 @@ import { getContext } from '../../utils/Context.js'; import type { EditorDocument } from '../EditorDocument/index.js'; -import { BlockTune } from '../BlockTune/index.js'; +import { PluginDataNode } from '../PluginDataNode/index.js'; import { InvalidNodeTypeError } from './errors/InvalidNodeTypeError.js'; import { NonExistingKeyError } from './errors/NonExistingKeyError.js'; import type { @@ -19,7 +19,7 @@ import { createBlockId, generateBlockId, createBlockToolName, - createBlockTuneName, + createPluginDataName, get, has, set, @@ -31,8 +31,8 @@ import { type DataKey, type BlockId, type BlockToolName, - type BlockTuneName, - type BlockTuneSerialized, + type PluginDataName, + type PluginDataSerialized, type BlockNodeDataSerialized, type BlockNodeDataSerializedValue, type BlockNodeSerialized, @@ -46,7 +46,7 @@ import type { DeepReadonly } from '../../utils/DeepReadonly.js'; import { DataNodeRemovedEvent, DataNodeAddedEvent, - TuneModifiedEvent, + PluginDataModifiedEvent, ValueModifiedEvent } from '@editorjs/model-types'; import type { Constructor } from '../../utils/types.js'; @@ -54,8 +54,8 @@ import { AlreadyExistingKeyError } from './errors/AlreadyExistingKeyError.js'; /** * BlockNode class represents a node in a tree-like structure used to store and manipulate Blocks in an editor document. - * A BlockNode can contain one or more child nodes of type TextNode or ValueNode. - * It can also be associated with one or more BlockTunes, which can modify the behavior of the BlockNode. + * A BlockNode can contain one or more child nodes of type TextNode or ValueNode, and carries the + * per-plugin data plugins use to store their own state for the block. */ export class BlockNode extends EventBus { /** @@ -79,9 +79,11 @@ export class BlockNode extends EventBus { #parent: EditorDocument | null; /** - * Private field representing the BlockTunes associated with the BlockNode + * Per-plugin data of this block, keyed by plugin name. The record has a null prototype because + * names come from documents: assigning a `__proto__` key to an ordinary object creates no own + * property, silently losing that plugin's data. */ - #tunes: Record; + #plugins: Record = Object.create(null) as Record; /** * Constructor for BlockNode class. @@ -90,33 +92,23 @@ export class BlockNode extends EventBus { * @param args.name - The name of the BlockNode. * @param [args.data] - The content of the BlockNode. * @param [args.parent] - The parent EditorDocument of the BlockNode. - * @param [args.tunes] - The BlockTunes associated with the BlockNode. + * @param [args.plugins] - Per-plugin data associated with the BlockNode. */ constructor({ id, name, data = {}, parent, - tunes = {}, + plugins = {}, }: BlockNodeConstructorParameters) { super(); this.#id = id !== undefined ? createBlockId(id) : generateBlockId(); this.#name = createBlockToolName(name); this.#parent = parent ?? null; - this.#tunes = mapObject( - tunes, - (tuneData: BlockTuneSerialized, tuneName: string) => { - const tune = new BlockTune({ - name: createBlockTuneName(tuneName), - data: tuneData, - }); - - this.#listenAndBubbleTuneEvent(tune, tuneName as BlockTuneName); - - return tune; - } - ); + Object.entries(plugins).forEach(([pluginName, pluginData]) => { + this.#createPluginDataNode(createPluginDataName(pluginName), pluginData); + }); this.#initialize(data); } @@ -150,10 +142,10 @@ export class BlockNode extends EventBus { } /** - * Getter to access BlockNode tunes + * Getter to access the block's per-plugin data nodes */ - public get tunes(): Readonly> { - return this.#tunes; + public get plugins(): Readonly> { + return this.#plugins; } /** @@ -165,16 +157,22 @@ export class BlockNode extends EventBus { entry => this.#serializeData(entry) ); - const serializedTunes = mapObject( - this.#tunes, - tune => tune.serialized - ); + /** + * Built through Object.fromEntries rather than by assignment: a plugin name is document data, + * and assigning a `__proto__` key to a plain object would set its prototype instead of adding + * the entry, silently dropping that plugin's data from the serialized block. + */ + const serializedPlugins = Object.fromEntries( + Object.entries(this.#plugins) + .filter(([, pluginData]) => !pluginData.isEmpty) + .map(([pluginName, pluginData]) => [pluginName, pluginData.serialized]) + ) as Record; return { id: this.#id, name: this.#name, data: serializedData, - tunes: serializedTunes, + plugins: serializedPlugins, }; } @@ -256,14 +254,21 @@ export class BlockNode extends EventBus { } /** - * Updates data in the BlockTune by the BlockTuneName - * @param tuneName - The name of the BlockTune - * @param data - The data to update the BlockTune with + * Updates one plugin's per-block data, creating the plugin's entry when the block has none yet. + * Each key is applied separately, so each change is emitted as its own event. + * @param pluginName - Name the data is stored under, by convention the plugin's `name` + * @param data - Keys to merge into the plugin's data; a key set to `undefined` is removed */ - public updateTuneData(tuneName: BlockTuneName, data: Record): void { + public updatePluginData(pluginName: PluginDataName, data: Record): void { + if (pluginName.length === 0) { + throw new Error('BlockNode: plugin data name must not be empty'); + } + + const pluginData = this.#plugins[pluginName] ?? this.#createPluginDataNode(pluginName); + Object.entries(data) .forEach(([key, value]) => { - this.#tunes[tuneName].update(key, value); + pluginData.update(key, value); }); } @@ -585,27 +590,50 @@ export class BlockNode extends EventBus { } /** - * Listens to BlockTune events and bubbles them to the BlockNode - * @param tune - BlockTune to listen to - * @param name - BlockTune name in the BlockNode data + * Creates a PluginDataNode for the passed name and starts bubbling its events. + * + * The node is created empty even when initial data is passed, and the listener is attached + * before any value is set, because PluginDataNode dispatches synchronously: attaching later + * would drop the events of the very first write. + * @param name - Name the data is stored under + * @param data - Initial data, applied after the listener is attached */ - #listenAndBubbleTuneEvent(tune: BlockTune, name: BlockTuneName): void { - tune.addEventListener( + #createPluginDataNode(name: PluginDataName, data?: PluginDataSerialized): PluginDataNode { + const pluginData = new PluginDataNode({ name }); + + this.#listenAndBubblePluginDataEvent(pluginData, name); + + this.#plugins[name] = pluginData; + + if (data !== undefined) { + Object.entries(data).forEach(([key, value]) => pluginData.update(key, value)); + } + + return pluginData; + } + + /** + * Listens to PluginDataNode events and bubbles them to the BlockNode + * @param pluginData - PluginDataNode to listen to + * @param name - Name the data is stored under + */ + #listenAndBubblePluginDataEvent(pluginData: PluginDataNode, name: PluginDataName): void { + pluginData.addEventListener( EventType.Changed, (event: Event): void => { if (!(event instanceof BaseDocumentEvent)) { // Stryker disable next-line StringLiteral - console.error('BlockNode: BlockTune should only emit BaseDocumentEvent'); + console.error('BlockNode: PluginDataNode should only emit BaseDocumentEvent'); return; } this.dispatchEvent( - new TuneModifiedEvent( - new PartialIndex({ tuneKey: (event.detail.index as PartialIndex).tuneKey, - tuneName: name }), + new PluginDataModifiedEvent( + new PartialIndex({ pluginKey: (event.detail.index as PartialIndex).pluginKey, + pluginName: name }), event.detail.data, - 'user' + getContext()! ) ); } diff --git a/packages/model/src/entities/BlockNode/types/BlockNodeConstructorParameters.ts b/packages/model/src/entities/BlockNode/types/BlockNodeConstructorParameters.ts index df7d6431..6abd42be 100644 --- a/packages/model/src/entities/BlockNode/types/BlockNodeConstructorParameters.ts +++ b/packages/model/src/entities/BlockNode/types/BlockNodeConstructorParameters.ts @@ -1,5 +1,5 @@ import type { EditorDocument } from '../../EditorDocument/index.js'; -import type { BlockId, BlockNodeDataSerialized, BlockTuneSerialized } from '@editorjs/model-types'; +import type { BlockId, BlockNodeDataSerialized, PluginDataSerialized } from '@editorjs/model-types'; export interface BlockNodeConstructorParameters { /** @@ -24,7 +24,7 @@ export interface BlockNodeConstructorParameters { parent?: EditorDocument; /** - * The BlockTunes associated with the BlockNode + * Per-plugin data associated with the BlockNode, keyed by plugin name */ - tunes?: Record; + plugins?: Record; } diff --git a/packages/model/src/entities/BlockTune/BlockTune.spec.ts b/packages/model/src/entities/BlockTune/BlockTune.spec.ts deleted file mode 100644 index 22366d7d..00000000 --- a/packages/model/src/entities/BlockTune/BlockTune.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { BlockTune } from './index.js'; -import { createBlockTuneName, EventType } from '@editorjs/model-types'; -import { TuneModifiedEvent } from '@editorjs/model-types'; -import { EventAction } from '@editorjs/model-types'; - -describe('BlockTune', () => { - const tuneName = createBlockTuneName('alignment'); - - describe('constructor', () => { - it('should have empty object as default data value', () => { - const blockTune = new BlockTune({ name: tuneName }); - - expect(blockTune.serialized) - .toEqual({}); - }); - }); - - describe('.update()', () => { - it('should add field to data object by key if it doesn\'t exist', () => { - // Arrange - const blockTune = new BlockTune({ - name: tuneName, - data: {}, - }); - - // Act - blockTune.update('align', 'left'); - - // Assert - expect(blockTune.serialized) - .toEqual({ - align: 'left', - }); - }); - - it('should update field in data object by key', () => { - // Arrange - const blockTune = new BlockTune({ - name: tuneName, - data: { - align: 'center', - }, - }); - - // Act - blockTune.update('align', 'right'); - - // Assert - expect(blockTune.serialized) - .toEqual({ - align: 'right', - }); - }); - - it('should emit TuneModifiedEvent with the new and previous values in details and tune name in index', () => { - const name = 'align'; - const value = 'center'; - const blockTune = new BlockTune({ - name: tuneName, - data: { - [name]: value, - }, - }); - const updatedValue = 'right'; - - let event: TuneModifiedEvent | null = null; - - blockTune.addEventListener(EventType.Changed, e => event = e as TuneModifiedEvent); - - blockTune.update(name, updatedValue); - - expect(event).toBeInstanceOf(TuneModifiedEvent); - expect(event).toHaveProperty('detail', expect.objectContaining({ - action: EventAction.Modified, - index: expect.objectContaining({ tuneKey: name }), - data: { - value: updatedValue, - previous: value, - }, - })); - }); - }); - - describe('.serialized', () => { - it('should return serialized version of the BlockTune', () => { - // Arrange - const tune = new BlockTune({ - name: tuneName, - data: { - background: 'transparent', - }, - }); - - // Act - const tuneSerialized = tune.serialized; - - // Assert - expect(tuneSerialized) - .toEqual( - { - background: 'transparent', - } - ); - }); - }); -}); diff --git a/packages/model/src/entities/BlockTune/__mocks__/index.ts b/packages/model/src/entities/BlockTune/__mocks__/index.ts deleted file mode 100644 index 03f21052..00000000 --- a/packages/model/src/entities/BlockTune/__mocks__/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createBlockTuneName, EventBus } from '@editorjs/model-types'; - -/** - * Mock for BlockTune class - */ -export class BlockTune extends EventBus { - /** - * Mock getter - */ - public get serialized(): object { - return {}; - } - - /** - * Mock method - */ - public update(): void { - return; - } -} - -export { createBlockTuneName }; diff --git a/packages/model/src/entities/BlockTune/index.ts b/packages/model/src/entities/BlockTune/index.ts deleted file mode 100644 index 7f6267d3..00000000 --- a/packages/model/src/entities/BlockTune/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { getContext } from '../../utils/Context.js'; -import type { BlockTuneConstructorParameters } from './types/index.js'; -import { - PartialIndex, - type BlockTuneSerialized, - type BlockTuneName, - EventBus, - TuneModifiedEvent -} from '@editorjs/model-types'; - -/** - * BlockTune class represents a set of additional information associated with a BlockNode. - * This information can be used by a BlockTool to modify the behavior of the BlockNode. - */ -export class BlockTune extends EventBus { - /** - * Private field representing the name of the tune - */ - #name: BlockTuneName; - - /** - * Private field representing any additional data associated with the tune - */ - #data: Record; - - /** - * Constructor for BlockTune class. - * @param args - BlockTune constructor arguments. - * @param args.name - The name of the tune. - * @param args.data - Any additional data associated with the tune. - */ - constructor({ name, data = {} }: BlockTuneConstructorParameters) { - super(); - - this.#name = name; - this.#data = data; - } - - /** - * Updates data associated with the tune. - * @param key - The key of the data to update - * @param value - The value to update the data with - */ - public update(key: string, value: unknown): void { - const previousValue = this.#data[key]; - - this.#data[key] = value; - - this.dispatchEvent( - new TuneModifiedEvent(new PartialIndex({ tuneKey: key }), { - value: this.#data[key], - previous: previousValue, - }, getContext()!) - ); - } - - /** - * Returns serialized version of the BlockTune. - */ - public get serialized(): BlockTuneSerialized { - return this.#data; - } -} diff --git a/packages/model/src/entities/BlockTune/types/BlockTuneConstructorParameters.ts b/packages/model/src/entities/BlockTune/types/BlockTuneConstructorParameters.ts deleted file mode 100644 index b0a1719b..00000000 --- a/packages/model/src/entities/BlockTune/types/BlockTuneConstructorParameters.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { BlockTuneName, BlockTuneSerialized } from '@editorjs/model-types'; - -export interface BlockTuneConstructorParameters { - /** - * The name of the tune - */ - name: BlockTuneName; - - /** - * Any additional data associated with the tune - */ - data?: BlockTuneSerialized; -} diff --git a/packages/model/src/entities/BlockTune/types/index.ts b/packages/model/src/entities/BlockTune/types/index.ts deleted file mode 100644 index a73ede9c..00000000 --- a/packages/model/src/entities/BlockTune/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { BlockTuneConstructorParameters } from './BlockTuneConstructorParameters.js'; diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index cc535305..42b7cad1 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -1,13 +1,13 @@ import { Index, PartialIndex } from '@editorjs/model-types'; import { EditorDocument } from './index.js'; -import { createBlockId, type DataKey, type InlineToolData, type InlineToolName, type BlockToolName, type BlockTuneName } from '@editorjs/model-types'; +import { createBlockId, type DataKey, type InlineToolData, type InlineToolName, type BlockToolName, type PluginDataName } from '@editorjs/model-types'; import { BlockNode } from '../BlockNode/index.js'; import { EventType } from '@editorjs/model-types'; import { BlockAddedEvent, BlockRemovedEvent, PropertyModifiedEvent, - TuneModifiedEvent + PluginDataModifiedEvent } from '@editorjs/model-types'; import { EventAction } from '@editorjs/model-types'; import { describe, jest } from '@jest/globals'; @@ -68,7 +68,7 @@ describe('EditorDocument', () => { fragments: [], }, }, - tunes: {}, + plugins: {}, }]; doc.initialize({ blocks }); @@ -127,7 +127,7 @@ describe('EditorDocument', () => { fragments: [], }, }, - tunes: {}, + plugins: {}, }]; doc.initialize({ blocks }); @@ -1097,12 +1097,12 @@ describe('EditorDocument', () => { }); }); - describe('.updateTuneData()', () => { + describe('.updatePluginData()', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('should call .updateTuneData() method of the BlockNode at the specific index', () => { + it('should call .updatePluginData() method of the BlockNode at the specific index', () => { const blocksData = [ { name: 'header' as BlockToolName, @@ -1127,25 +1127,25 @@ describe('EditorDocument', () => { const blockNode = document.getBlock(i); jest - .spyOn(blockNode, 'updateTuneData') + .spyOn(blockNode, 'updatePluginData') // eslint-disable-next-line @typescript-eslint/no-empty-function -- mock of the method .mockImplementation(() => { }); }); const blockIndexToUpdate = 1; - const tuneName = 'blockFormatting' as BlockTuneName; + const pluginName = 'blockFormatting' as PluginDataName; const updateData = { align: 'right', }; - document.updateTuneData(blockIndexToUpdate, tuneName, updateData); + document.updatePluginData(blockIndexToUpdate, pluginName, updateData); - expect(document.getBlock(blockIndexToUpdate).updateTuneData) - .toHaveBeenCalledWith(tuneName, updateData); + expect(document.getBlock(blockIndexToUpdate).updatePluginData) + .toHaveBeenCalledWith(pluginName, updateData); }); - it('should not call .updateTuneData() method of other BlockNodes', () => { + it('should not call .updatePluginData() method of other BlockNodes', () => { const blocksData = [ { name: 'header' as BlockToolName, @@ -1170,7 +1170,7 @@ describe('EditorDocument', () => { const blockNode = document.getBlock(i); jest - .spyOn(blockNode, 'updateTuneData') + .spyOn(blockNode, 'updatePluginData') // eslint-disable-next-line @typescript-eslint/no-empty-function -- mock of the method .mockImplementation(() => { }); @@ -1179,19 +1179,19 @@ describe('EditorDocument', () => { }); const blockIndexToUpdate = 1; - const tuneName = 'blockFormatting' as BlockTuneName; + const pluginName = 'blockFormatting' as PluginDataName; const updateData = { align: 'right', }; - document.updateTuneData(blockIndexToUpdate, tuneName, updateData); + document.updatePluginData(blockIndexToUpdate, pluginName, updateData); blockNodes.forEach((blockNode, index) => { if (index === blockIndexToUpdate) { return; } - expect(blockNode.updateTuneData) + expect(blockNode.updatePluginData) .not .toHaveBeenCalled(); }); @@ -1202,12 +1202,12 @@ describe('EditorDocument', () => { identifier: 'document', }); const blockIndexOutOfBound = document.length + 1; - const tuneName = 'blockFormatting' as BlockTuneName; + const pluginName = 'blockFormatting' as PluginDataName; const updateData = { align: 'right', }; - const action = (): void => document.updateTuneData(blockIndexOutOfBound, tuneName, updateData); + const action = (): void => document.updatePluginData(blockIndexOutOfBound, pluginName, updateData); expect(action) .toThrow('Index out of bounds'); @@ -1500,6 +1500,34 @@ describe('EditorDocument', () => { expect(spy) .toHaveBeenCalledWith(blockIndex, dataKey, 'bold', 0, rangeEnd); }); + + it('should call .updatePluginData() when a plugin data index is provided', () => { + const spy = jest.spyOn(document, 'updatePluginData'); + const pluginName = 'anchors' as PluginDataName; + const index = Index.pluginData(blockIndex, pluginName, 'id'); + + document.modifyData(index, { + value: 'intro', + previous: undefined, + }); + + expect(spy) + .toHaveBeenCalledWith(blockIndex, pluginName, { id: 'intro' }); + }); + + it('should apply the removal of a plugin data key when the new value is undefined', () => { + const spy = jest.spyOn(document, 'updatePluginData'); + const pluginName = 'anchors' as PluginDataName; + const index = Index.pluginData(blockIndex, pluginName, 'id'); + + document.modifyData(index, { + value: undefined, + previous: 'intro', + }); + + expect(spy) + .toHaveBeenCalledWith(blockIndex, pluginName, { id: undefined }); + }); }); describe('.removeText()', () => { @@ -1707,11 +1735,11 @@ describe('EditorDocument', () => { document.addEventListener(EventType.Changed, handler); - const partial = new PartialIndex({ tuneKey: 'value', - tuneName: 'tune' as BlockTuneName }); + const partial = new PartialIndex({ pluginKey: 'value', + pluginName: 'anchors' as PluginDataName }); blockNode.dispatchEvent( - new TuneModifiedEvent( + new PluginDataModifiedEvent( partial, { value: 'value', @@ -1722,22 +1750,22 @@ describe('EditorDocument', () => { ); expect(handler) - .toHaveBeenCalledWith(expect.any(TuneModifiedEvent)); + .toHaveBeenCalledWith(expect.any(PluginDataModifiedEvent)); }); it('should re-emit events from the BlockNode with updated index', () => { - let event: TuneModifiedEvent | null = null; + let event: PluginDataModifiedEvent | null = null; const handler = (e: Event): void => { - event = e as TuneModifiedEvent; + event = e as PluginDataModifiedEvent; }; document.addEventListener(EventType.Changed, handler); - const partial = new PartialIndex({ tuneKey: 'value', - tuneName: 'tune' as BlockTuneName }); + const partial = new PartialIndex({ pluginKey: 'value', + pluginName: 'anchors' as PluginDataName }); blockNode.dispatchEvent( - new TuneModifiedEvent( + new PluginDataModifiedEvent( partial, { value: 'value', @@ -1749,8 +1777,8 @@ describe('EditorDocument', () => { expect(event) .toHaveProperty('detail.index', expect.objectContaining({ - tuneKey: 'value', - tuneName: 'tune', + pluginKey: 'value', + pluginName: 'anchors', blockIndex: index, })); }); diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index 62140506..c334eeb4 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -6,6 +6,7 @@ import { BlockIndex, DataIndex, TextIndex, + PluginDataIndex, type PartialIndex, EventBus, EventType, @@ -16,11 +17,11 @@ import { type Properties, type InlineToolData, type InlineToolName, - type BlockTuneEvents, + type PluginDataEvents, type TextNodeEvents, type ValueNodeEvents, type EditorDocumentSerialized, - type BlockTuneName, + type PluginDataName, type BlockNodeDataSerializedValue, type BlockNodeSerialized, type BlockNodeInit @@ -330,18 +331,18 @@ export class EditorDocument extends EventBus { } /** - * Updates BlockTune data associated with the BlockNode at the specified index or id. + * Updates one plugin's per-block data on the BlockNode at the specified index or id. * @param blockIndexOrId - The index or block id of the BlockNode to update - * @param tuneName - The name of the BlockTune to update - * @param data - The data to update the BlockTune with + * @param pluginName - Name the data is stored under, by convention the plugin's `name` + * @param data - Keys to merge into the plugin's data * @throws Error if the index is out of bounds */ - public updateTuneData(blockIndexOrId: BlockIndexOrId, tuneName: BlockTuneName, data: Record): void { + public updatePluginData(blockIndexOrId: BlockIndexOrId, pluginName: PluginDataName, data: Record): void { const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); - this.#children[resolvedIndex].updateTuneData(tuneName, data); + this.#children[resolvedIndex].updatePluginData(pluginName, data); } /** @@ -518,6 +519,10 @@ export class EditorDocument extends EventBus { this.unformat(index.blockIndex!, index.dataKey!, (data.previous as TextUnformattedEventData).tool, index.textRange![0], index.textRange![1]); } } + if (index instanceof PluginDataIndex) { + this.updatePluginData(index.blockIndex, index.pluginName, { [index.pluginKey]: data.value }); + } + /** * @todo implement other actions */ @@ -571,7 +576,7 @@ export class EditorDocument extends EventBus { .resolve(); this.dispatchEvent( - new (event.constructor as Constructor)( + new (event.constructor as Constructor)( completeIndex, event.detail.data ) diff --git a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts new file mode 100644 index 00000000..e31e9e93 --- /dev/null +++ b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts @@ -0,0 +1,432 @@ +import { PluginDataNode } from './index.js'; +import { createPluginDataName, EventType } from '@editorjs/model-types'; +import { PluginDataModifiedEvent } from '@editorjs/model-types'; +import { EventAction } from '@editorjs/model-types'; + +describe('PluginDataNode', () => { + const pluginName = createPluginDataName('alignment'); + + describe('constructor', () => { + it('should have empty object as default data value', () => { + const pluginData = new PluginDataNode({ name: pluginName }); + + expect(pluginData.serialized) + .toEqual({}); + }); + }); + + describe('.update()', () => { + it('should add field to data object by key if it doesn\'t exist', () => { + // Arrange + const pluginData = new PluginDataNode({ + name: pluginName, + data: {}, + }); + + // Act + pluginData.update('align', 'left'); + + // Assert + expect(pluginData.serialized) + .toEqual({ + align: 'left', + }); + }); + + it('should update field in data object by key', () => { + // Arrange + const pluginData = new PluginDataNode({ + name: pluginName, + data: { + align: 'center', + }, + }); + + // Act + pluginData.update('align', 'right'); + + // Assert + expect(pluginData.serialized) + .toEqual({ + align: 'right', + }); + }); + + it('should emit PluginDataModifiedEvent with the new and previous values in details and plugin key in index', () => { + const name = 'align'; + const value = 'center'; + const pluginData = new PluginDataNode({ + name: pluginName, + data: { + [name]: value, + }, + }); + const updatedValue = 'right'; + + let event: PluginDataModifiedEvent | null = null; + + pluginData.addEventListener(EventType.Changed, e => event = e as PluginDataModifiedEvent); + + pluginData.update(name, updatedValue); + + expect(event).toBeInstanceOf(PluginDataModifiedEvent); + expect(event).toHaveProperty('detail', expect.objectContaining({ + action: EventAction.Modified, + index: expect.objectContaining({ pluginKey: name }), + data: { + value: updatedValue, + previous: value, + }, + })); + }); + + it('should remove the key when the value is undefined', () => { + const pluginData = new PluginDataNode({ + name: pluginName, + data: { + align: 'left', + visible: true, + }, + }); + + pluginData.update('align', undefined); + + expect(pluginData.serialized).toEqual({ visible: true }); + }); + + it('should emit the previous value when a key is removed', () => { + const pluginData = new PluginDataNode({ + name: pluginName, + data: { align: 'left' }, + }); + + let event: PluginDataModifiedEvent | null = null; + + pluginData.addEventListener(EventType.Changed, e => event = e as PluginDataModifiedEvent); + + pluginData.update('align', undefined); + + expect(event).toHaveProperty('detail', expect.objectContaining({ + data: { + value: undefined, + previous: 'left', + }, + })); + }); + }); + + describe('a write that changes nothing', () => { + /** + * Every dispatch here becomes an OT operation and an undo step. Plugins re-apply their + * current state routinely -- on every menu open, for an item deriving `isActive` from it -- + * so a write that stores what is already stored must stay silent. + */ + it('should not emit when the key already holds that value', () => { + const pluginData = new PluginDataNode({ + name: pluginName, + data: { align: 'left' }, + }); + const listener = jest.fn(); + + pluginData.addEventListener(EventType.Changed, listener); + + pluginData.update('align', 'left'); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should not emit when removing a key that is not there', () => { + const pluginData = new PluginDataNode({ + name: pluginName, + data: { align: 'left' }, + }); + const listener = jest.fn(); + + pluginData.addEventListener(EventType.Changed, listener); + + pluginData.update('visible', undefined); + + expect(listener).not.toHaveBeenCalled(); + }); + + /** + * The key is present and holds `undefined`, which only a loaded document can produce, so + * removing it is a real change even though both sides of the comparison are `undefined` + */ + it('should still emit when a key that explicitly holds undefined is removed', () => { + const pluginData = new PluginDataNode({ name: pluginName, + data: { id: undefined } }); + const events: PluginDataModifiedEvent[] = []; + + pluginData.addEventListener(EventType.Changed, (event) => { + events.push(event as PluginDataModifiedEvent); + }); + + pluginData.update('id', undefined); + + expect(events).toHaveLength(1); + expect(pluginData.isEmpty).toBe(true); + }); + + it('should still emit when the value actually differs', () => { + const pluginData = new PluginDataNode({ + name: pluginName, + data: { align: 'left' }, + }); + const listener = jest.fn(); + + pluginData.addEventListener(EventType.Changed, listener); + + pluginData.update('align', 'right'); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should still emit when a key is written for the first time with an undefined-free value', () => { + const pluginData = new PluginDataNode({ name: pluginName }); + const listener = jest.fn(); + + pluginData.addEventListener(EventType.Changed, listener); + + pluginData.update('align', 'left'); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should leave the stored data untouched', () => { + const pluginData = new PluginDataNode({ + name: pluginName, + data: { align: 'left' }, + }); + + pluginData.update('align', 'left'); + pluginData.update('visible', undefined); + + expect(pluginData.serialized).toEqual({ align: 'left' }); + }); + }); + + describe('.isEmpty', () => { + it('should be true when the node holds no keys', () => { + expect(new PluginDataNode({ name: pluginName }).isEmpty).toBe(true); + }); + + it('should be false when the node holds a key', () => { + expect(new PluginDataNode({ name: pluginName, + data: { align: 'left' } }).isEmpty).toBe(false); + }); + + it('should become true again once every key is removed', () => { + const pluginData = new PluginDataNode({ name: pluginName, + data: { align: 'left' } }); + + pluginData.update('align', undefined); + + expect(pluginData.isEmpty).toBe(true); + }); + }); + + describe('keys that collide with Object.prototype', () => { + it('should keep a __proto__ key as data', () => { + const pluginData = new PluginDataNode({ name: pluginName }); + + /** + * Parsed rather than written as a literal: an object literal's `__proto__` is the + * prototype setter, while JSON.parse creates an own property -- and a document loaded + * from storage arrives the second way + */ + const loaded = JSON.parse('{"__proto__": "evil", "ok": 1}') as Record; + + Object.entries(loaded).forEach(([key, value]) => pluginData.update(key, value)); + + // A computed key, for the same reason: `{ '__proto__': x }` is still the setter. + expect(pluginData.serialized).toEqual({ + ['__proto__']: 'evil', + ok: 1, + }); + }); + + it('should treat a key named toString as absent before it is written', () => { + const pluginData = new PluginDataNode({ name: pluginName }); + const events: PluginDataModifiedEvent[] = []; + + pluginData.addEventListener(EventType.Changed, (event) => { + events.push(event as PluginDataModifiedEvent); + }); + + pluginData.update('toString', 'x'); + + // Read off the prototype, the previous value would be a function -- onto the undo stack. + expect(events).toHaveLength(1); + expect(events[0].detail.data.previous).toBeUndefined(); + }); + + it('should not report inherited data for a key it was never given', () => { + const pluginData = new PluginDataNode({ name: pluginName, + data: { id: 'one' } }); + + expect(pluginData.serialized.toString).toBeUndefined(); + }); + }); + + describe('values the caller still holds a reference to', () => { + it('should store a copy rather than the object it was given', () => { + const pluginData = new PluginDataNode({ name: pluginName }); + const settings = { align: 'left' }; + + pluginData.update('settings', settings); + settings.align = 'center'; + + /** + * Sharing the object lets an edit to it change the Model with no write at all, so the document moves while + * nothing is recorded and no collaborator is told + */ + expect(pluginData.serialized).toEqual({ settings: { align: 'left' } }); + }); + + it('should store a copy of nested values too', () => { + const pluginData = new PluginDataNode({ name: pluginName }); + const settings = { nested: { depth: 1 }, + items: [{ id: 'one' }] }; + + pluginData.update('settings', settings); + settings.nested.depth = 2; + settings.items[0].id = 'two'; + + expect(pluginData.serialized).toEqual({ settings: { nested: { depth: 1 }, + items: [{ id: 'one' }] } }); + }); + + it('should emit when a value is mutated and written back', () => { + const pluginData = new PluginDataNode({ name: pluginName }); + const settings = { align: 'left' }; + + pluginData.update('settings', settings); + + const events: PluginDataModifiedEvent[] = []; + + pluginData.addEventListener(EventType.Changed, (event) => { + events.push(event as PluginDataModifiedEvent); + }); + + settings.align = 'center'; + pluginData.update('settings', settings); + + /** + * The write-back is the only announcement a read-modify-write makes, and over a shared object identity finds + * it unchanged: no operation for collaborators, no step to undo, and two documents quietly diverged + */ + expect(events).toHaveLength(1); + expect(events[0]?.detail.data).toEqual({ value: { align: 'center' }, + previous: { align: 'left' } }); + expect(pluginData.serialized).toEqual({ settings: { align: 'center' } }); + }); + + it('should not emit when an equal object is written again', () => { + const pluginData = new PluginDataNode({ name: pluginName, + data: { settings: { align: 'left', + nested: { depth: 1 } } } }); + const events: PluginDataModifiedEvent[] = []; + + pluginData.addEventListener(EventType.Changed, (event) => { + events.push(event as PluginDataModifiedEvent); + }); + + /** + * A different object holding the same state. Plugins re-apply their current state routinely, and once stored + * values are copies an identity check would never match one + */ + pluginData.update('settings', { align: 'left', + nested: { depth: 1 } }); + + expect(events).toHaveLength(0); + }); + + it('should keep the recorded change out of reach of a later edit', () => { + const pluginData = new PluginDataNode({ name: pluginName }); + const settings = { align: 'left' }; + const events: PluginDataModifiedEvent[] = []; + + pluginData.addEventListener(EventType.Changed, (event) => { + events.push(event as PluginDataModifiedEvent); + }); + + pluginData.update('settings', settings); + settings.align = 'center'; + + /** + * The payload becomes an operation and an undo step, both of which outlive the call + */ + expect(events[0]?.detail.data.value).toEqual({ align: 'left' }); + }); + + it('should copy the data it is constructed with', () => { + const data = { settings: { align: 'left' } }; + const pluginData = new PluginDataNode({ name: pluginName, + data }); + + data.settings.align = 'center'; + + expect(pluginData.serialized).toEqual({ settings: { align: 'left' } }); + }); + }); + + describe('.serialized', () => { + it('should hand back a copy rather than the record it keeps', () => { + const pluginData = new PluginDataNode({ name: pluginName, + data: { id: 'one', + nested: { depth: 1 } } }); + + const snapshot = pluginData.serialized as Record; + + snapshot.id = 'two'; + (snapshot.nested as Record).depth = 2; + + /** + * Handing out the live record makes read-modify-write silent data loss: the write-back + * finds the value already changed and the no-op guard emits nothing + */ + expect(pluginData.serialized).toEqual({ id: 'one', + nested: { depth: 1 } }); + }); + + it('should still emit when a value read from it is written back changed', () => { + const pluginData = new PluginDataNode({ name: pluginName, + data: { id: 'one' } }); + const events: PluginDataModifiedEvent[] = []; + + pluginData.addEventListener(EventType.Changed, (event) => { + events.push(event as PluginDataModifiedEvent); + }); + + const snapshot = pluginData.serialized as Record; + + snapshot.id = 'two'; + pluginData.update('id', snapshot.id); + + expect(events).toHaveLength(1); + expect(pluginData.serialized).toEqual({ id: 'two' }); + }); + + it('should return serialized version of the plugin data', () => { + // Arrange + const pluginData = new PluginDataNode({ + name: pluginName, + data: { + background: 'transparent', + }, + }); + + // Act + const pluginDataSerialized = pluginData.serialized; + + // Assert + expect(pluginDataSerialized) + .toEqual( + { + background: 'transparent', + } + ); + }); + }); +}); diff --git a/packages/model/src/entities/PluginDataNode/__mocks__/index.ts b/packages/model/src/entities/PluginDataNode/__mocks__/index.ts new file mode 100644 index 00000000..9dc275e7 --- /dev/null +++ b/packages/model/src/entities/PluginDataNode/__mocks__/index.ts @@ -0,0 +1,27 @@ +import { EventBus } from '@editorjs/model-types'; + +/** + * Mock for PluginDataNode class + */ +export class PluginDataNode extends EventBus { + /** + * Mock getter + */ + public get serialized(): object { + return {}; + } + + /** + * Mock getter + */ + public get isEmpty(): boolean { + return false; + } + + /** + * Mock method + */ + public update(): void { + return; + } +} diff --git a/packages/model/src/entities/PluginDataNode/index.ts b/packages/model/src/entities/PluginDataNode/index.ts new file mode 100644 index 00000000..5e4e33d4 --- /dev/null +++ b/packages/model/src/entities/PluginDataNode/index.ts @@ -0,0 +1,145 @@ +import { getContext } from '../../utils/Context.js'; +import { deepEqual } from '../../utils/deepEqual.js'; +import type { PluginDataNodeConstructorParameters } from './types/index.js'; +import { + PartialIndex, + type PluginDataSerialized, + type PluginDataName, + EventBus, + PluginDataModifiedEvent +} from '@editorjs/model-types'; + +/** + * Copies a single plugin data value, so that neither side can reach the other's through it. Objects and arrays are + * detached down to their nested values; anything else has nothing to share and is returned as it is + * @param value - the value to copy + */ +function copyValue(value: unknown): unknown { + return typeof value === 'object' && value !== null ? structuredClone(value) : value; +} + +/** + * Copies a plugin data record so that callers hold something of their own. + * + * `structuredClone` detaches the values; the top level is re-keyed onto a null prototype to + * match the record it came from, so a key named `toString` reads as absent rather than as an + * inherited function + * @param data - the record to copy + */ +function detach(data: Record): PluginDataSerialized { + return Object.assign(Object.create(null) as Record, structuredClone(data)); +} + +/** + * PluginDataNode holds the per-block data owned by a single plugin. + * The data is a flat key/value map: a plugin reads and writes its own keys, and every change + * is emitted as a separate event so concurrent writes to different keys don't collide. + */ +export class PluginDataNode extends EventBus { + /** + * Name the data is stored under, by convention the owning plugin's `name` + */ + #name: PluginDataName; + + /** + * The plugin's data + */ + #data: Record; + + /** + * Constructor for PluginDataNode class. + * @param args - PluginDataNode constructor arguments. + * @param args.name - Name the data is stored under. + * @param args.data - Initial data of the plugin. + */ + constructor({ name, data = {} }: PluginDataNodeConstructorParameters) { + super(); + + this.#name = name; + + /** + * Copied onto a null prototype, as the block's own plugin store is: keys here are document + * data, so a `__proto__` key loaded from a document would otherwise reach the prototype + * setter instead of becoming an own property, and vanish from serialization. A first write + * to a key like `toString` would also read the inherited function as its previous value and + * put that on the undo stack. + * + * Deep-copied as writes are, below: the document object this came from is the caller's, and sharing its + * nested values would let an edit to it change the Model + */ + this.#data = detach(data); + } + + /** + * Name the data is stored under + */ + public get name(): PluginDataName { + return this.#name; + } + + /** + * True when the node holds no keys, so the owning block can leave it out of serialization + */ + public get isEmpty(): boolean { + return Object.keys(this.#data).length === 0; + } + + /** + * Updates one key of the plugin's data. Passing `undefined` removes the key, + * so undoing the first write to a key restores the original shape of the data. + * @param key - The key of the data to update + * @param value - The value to update the data with, or `undefined` to remove the key + */ + public update(key: string, value: unknown): void { + const previousValue = this.#data[key]; + + /** + * Storing what is already stored changes nothing, and every event here becomes an operation + * for collaborators and a step on the undo stack. Plugins re-apply their current state + * routinely, and an undo step that undoes nothing is worse than no step at all. + * + * Compared structurally rather than by identity: what is stored is a copy of what was written, so identity + * could only ever match a primitive, and re-applying the same object state would emit every time. + * + * The second clause is the removal case: both sides are `undefined` when a key holding + * `undefined` is removed, which is a real change, and when an absent key is removed, which + * is not. + */ + if (deepEqual(previousValue, value) && (value !== undefined || !(key in this.#data))) { + return; + } + + if (value === undefined) { + delete this.#data[key]; + } else { + /** + * Stored as a copy: over the caller's own object an edit has already changed the Model, so the write-back that + * should have announced it finds nothing to say -- no operation for collaborators, no step to undo + */ + this.#data[key] = copyValue(value); + } + + this.dispatchEvent( + new PluginDataModifiedEvent(new PartialIndex({ pluginKey: key }), { + /** + * Copied again: this payload becomes an operation and an undo step, both of which outlive the call. + * `previousValue` needs none -- it is the Model's own former copy, just dropped above and never handed out + */ + value: copyValue(value), + previous: previousValue, + }, getContext()!) + ); + } + + /** + * Returns serialized version of the plugin's data. + * + * Detached from the node, because this is what reaches callers through `getPluginData` and + * `api.document.data`. Handing out the live record makes read-modify-write a silent data + * loss: the caller's edit has already changed the Model, so writing it back finds the value + * unchanged and emits nothing -- no operation for collaborators, no step to undo + */ + public get serialized(): PluginDataSerialized { + return detach(this.#data); + } +} diff --git a/packages/model/src/entities/PluginDataNode/types/PluginDataNodeConstructorParameters.ts b/packages/model/src/entities/PluginDataNode/types/PluginDataNodeConstructorParameters.ts new file mode 100644 index 00000000..3669bf3d --- /dev/null +++ b/packages/model/src/entities/PluginDataNode/types/PluginDataNodeConstructorParameters.ts @@ -0,0 +1,13 @@ +import type { PluginDataName, PluginDataSerialized } from '@editorjs/model-types'; + +export interface PluginDataNodeConstructorParameters { + /** + * Name the data is stored under, by convention the owning plugin's `name` + */ + name: PluginDataName; + + /** + * Initial data of the plugin + */ + data?: PluginDataSerialized; +} diff --git a/packages/model/src/entities/PluginDataNode/types/index.ts b/packages/model/src/entities/PluginDataNode/types/index.ts new file mode 100644 index 00000000..49c4f607 --- /dev/null +++ b/packages/model/src/entities/PluginDataNode/types/index.ts @@ -0,0 +1 @@ +export type { PluginDataNodeConstructorParameters } from './PluginDataNodeConstructorParameters.js'; diff --git a/packages/model/src/entities/index.ts b/packages/model/src/entities/index.ts index 0db7aa2c..1fc3790c 100644 --- a/packages/model/src/entities/index.ts +++ b/packages/model/src/entities/index.ts @@ -1,5 +1,5 @@ export * from './EditorDocument/index.js'; export * from './BlockNode/index.js'; -export * from './BlockTune/index.js'; +export * from './PluginDataNode/index.js'; export * from './ValueNode/index.js'; export * from './inline-fragments/index.js'; diff --git a/packages/model/src/utils/deepEqual.spec.ts b/packages/model/src/utils/deepEqual.spec.ts new file mode 100644 index 00000000..f89c4c0a --- /dev/null +++ b/packages/model/src/utils/deepEqual.spec.ts @@ -0,0 +1,87 @@ +import { deepEqual } from './deepEqual.js'; + +describe('deepEqual', () => { + it('should treat equal primitives as equal', () => { + expect(deepEqual('a', 'a')).toBe(true); + expect(deepEqual(1, 1)).toBe(true); + expect(deepEqual(undefined, undefined)).toBe(true); + expect(deepEqual(null, null)).toBe(true); + }); + + it('should treat differing primitives as different', () => { + expect(deepEqual('a', 'b')).toBe(false); + expect(deepEqual(1, '1')).toBe(false); + expect(deepEqual(null, undefined)).toBe(false); + }); + + it('should compare plain records by their keys', () => { + expect(deepEqual({ a: 1, + b: { c: 2 } }, { a: 1, + b: { c: 2 } })).toBe(true); + expect(deepEqual({ a: 1 }, { a: 2 })).toBe(false); + expect(deepEqual({ a: 1 }, { a: 1, + b: 2 })).toBe(false); + }); + + it('should compare arrays element by element', () => { + expect(deepEqual([1, { a: 2 }], [1, { a: 2 }])).toBe(true); + expect(deepEqual([1, 2], [2, 1])).toBe(false); + expect(deepEqual([], {})).toBe(false); + }); + + it('should report arrays of different lengths as different', () => { + expect(deepEqual([1], [1, 2])).toBe(false); + expect(deepEqual([1, 2], [1])).toBe(false); + }); + + /** + * An array and an array-like record agree on `length` and on every index, so without the + * kind check in front of them they would compare equal + */ + it('should report an array and an array-like record as different', () => { + expect(deepEqual([], { length: 0 })).toBe(false); + expect(deepEqual({ length: 0 }, [])).toBe(false); + }); + + /** + * One side keyed and the other not: both have no own enumerable properties, so falling + * through to the key comparison would call them equal + */ + it('should report a non-plain object as different from a plain record', () => { + expect(deepEqual(new Date(1), {})).toBe(false); + expect(deepEqual({}, new Date(1))).toBe(false); + }); + + it('should compare a record with a null prototype by its keys too', () => { + const nullProto = Object.assign(Object.create(null) as Record, { a: 1 }); + + expect(deepEqual(nullProto, { a: 1 })).toBe(true); + }); + + /** + * The store holds `structuredClone` output, which a host may build on another realm's `Object.prototype`: + * comparing prototypes would call such a record exotic and report every write as a change + */ + it('should compare a structuredClone of a record by its keys too', () => { + expect(deepEqual(structuredClone({ a: 1, + b: { c: 2 } }), { a: 1, + b: { c: 2 } })).toBe(true); + }); + + /** + * A value whose state is not in its own enumerable properties: comparing two by key finds none on either and + * calls them equal, swallowing a write that changed one + */ + it('should report two distinct non-plain objects as different', () => { + expect(deepEqual(new Date(1), new Date(2))).toBe(false); + expect(deepEqual(new Date(1), new Date(1))).toBe(false); + expect(deepEqual(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false); + expect(deepEqual(new Set([1]), new Set([2]))).toBe(false); + }); + + it('should still treat the same non-plain object as equal to itself', () => { + const date = new Date(1); + + expect(deepEqual(date, date)).toBe(true); + }); +}); diff --git a/packages/model/src/utils/deepEqual.ts b/packages/model/src/utils/deepEqual.ts new file mode 100644 index 00000000..5d929cee --- /dev/null +++ b/packages/model/src/utils/deepEqual.ts @@ -0,0 +1,53 @@ +/** + * Whether a value is a record whose own keys describe it: a `Date` or a `Map` has none, so two of them would compare + * equal. Read from the built-in tag, not the prototype, which `structuredClone` output may not share with this realm + * @param value - the object to classify + */ +function isKeyedObject(value: object): boolean { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +/** + * Deep structural equality for two plain JSON-like values: keyed records by their own keys, arrays element by element, + * everything else by `===`. Identity is not the question where the value may be a copy of what is already stored + * @param a - first value to compare + * @param b - second value to compare + */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + + if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) { + return false; + } + + const aIsArray = Array.isArray(a); + + if (aIsArray !== Array.isArray(b)) { + return false; + } + + if (aIsArray) { + const arrA = a as unknown[]; + const arrB = b as unknown[]; + + return arrA.length === arrB.length && arrA.every((item, index) => deepEqual(item, arrB[index])); + } + + /** + * Not identical, and not something whose own keys describe it: comparing two `Date`s by key would call them equal + * and swallow a write that changed one. Such a value is outside the JSON-serializable contract anyway + */ + if (!isKeyedObject(a) || !isKeyedObject(b)) { + return false; + } + + const objA = a as Record; + const objB = b as Record; + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + return keysA.length === keysB.length + && keysA.every(key => Object.prototype.hasOwnProperty.call(objB, key) && deepEqual(objA[key], objB[key])); +} diff --git a/packages/model/src/utils/index.ts b/packages/model/src/utils/index.ts index a4bc214f..e98da4fd 100644 --- a/packages/model/src/utils/index.ts +++ b/packages/model/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './textUtils.js'; +export * from './deepEqual.js'; export * from './isSameInlineData.js'; diff --git a/packages/model/src/utils/isSameInlineData.ts b/packages/model/src/utils/isSameInlineData.ts index 77ae5be0..708488a1 100644 --- a/packages/model/src/utils/isSameInlineData.ts +++ b/packages/model/src/utils/isSameInlineData.ts @@ -1,40 +1,5 @@ import type { InlineToolData } from '@editorjs/model-types'; - -/** - * Deep structural equality check for two plain JSON-like values. - * @param a - first value to compare - * @param b - second value to compare - */ -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) { - return true; - } - - if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) { - return false; - } - - const aIsArray = Array.isArray(a); - - if (aIsArray !== Array.isArray(b)) { - return false; - } - - if (aIsArray) { - const arrA = a as unknown[]; - const arrB = b as unknown[]; - - return arrA.length === arrB.length && arrA.every((item, index) => deepEqual(item, arrB[index])); - } - - const objA = a as Record; - const objB = b as Record; - const keysA = Object.keys(objA); - const keysB = Object.keys(objB); - - return keysA.length === keysB.length - && keysA.every(key => Object.prototype.hasOwnProperty.call(objB, key) && deepEqual(objA[key], objB[key])); -} +import { deepEqual } from './deepEqual.js'; /** * Determines whether two inline tool data objects should be treated as equal. diff --git a/packages/ot-server/src/DocumentManager.spec.ts b/packages/ot-server/src/DocumentManager.spec.ts index 4628a7e4..9ddda3fc 100644 --- a/packages/ot-server/src/DocumentManager.spec.ts +++ b/packages/ot-server/src/DocumentManager.spec.ts @@ -80,7 +80,7 @@ describe('DocumentManager', () => { blocks: [ expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -141,7 +141,7 @@ describe('DocumentManager', () => { blocks: [ expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -211,7 +211,7 @@ describe('DocumentManager', () => { blocks: [ expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', @@ -284,7 +284,7 @@ describe('DocumentManager', () => { blocks: [ expect.objectContaining({ name: 'paragraph', - tunes: {}, + plugins: {}, data: { text: { $t: 't', diff --git a/packages/playground/package.json b/packages/playground/package.json index 1dc9f234..f7cf024d 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -9,7 +9,7 @@ "build": "yarn build:dependencies && vue-tsc && vite build", "build:declaration": "", "preview": "yarn build:dependencies && vite preview", - "build:dependencies": "yarn workspaces foreach -Rpt --from $npm_package_name --exclude $npm_package_name run build", + "build:dependencies": "yarn workspaces foreach -Rp --topological-dev --from $npm_package_name --exclude $npm_package_name run build", "dev:dependencies": "yarn workspaces foreach -Rp --from $npm_package_name --exclude $npm_package_name run dev", "lint": "eslint", "lint:ci": "yarn lint --max-warnings 0", diff --git a/packages/plugins/default-block-settings-plugin/.gitignore b/packages/plugins/default-block-settings-plugin/.gitignore new file mode 100644 index 00000000..854a697d --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/.gitignore @@ -0,0 +1,24 @@ +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Swap the comments on the following lines if you don't wish to use zero-installs +# Documentation here: https://yarnpkg.com/features/zero-installs +#!.yarn/cache +#.pnp.* + +# IDE +.idea/* + +node_modules/* +dist/* + +# tests +coverage/ +reports/ + +# stryker temp files +.stryker-tmp diff --git a/packages/plugins/default-block-settings-plugin/README.md b/packages/plugins/default-block-settings-plugin/README.md new file mode 100644 index 00000000..9ea7504e --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/README.md @@ -0,0 +1,32 @@ +# Default Block Settings Plugin + +Built-in Editor.js plugin supplying the default entries of the block settings menu: **Move up**, +**Move down** and **Delete**. + +It is an ordinary plugin, not a special kind of entity — the same `block-settings` API any other +plugin uses, registered at `order: 1000` so its items sit last in the menu. It is the reference +example of what used to be called a "block tune". + +## Behaviour + +- **Move up** / **Move down** call `api.blocks.move`. Each is disabled at the document boundary, + evaluated when the menu is built. +- **Delete** asks for confirmation first, and deletes by block id on the second activation. + +Every handler resolves the target block's current index from its id at activation time rather than +reusing the index the menu was built with. A menu can stay open while a collaborator inserts a +block above the target, or while an undo runs, and acting on the stale index would move the wrong +block. A disabled state can go stale the same way, but that only greys an item out. + +Without a registered `block-settings` plugin — a headless `Core`, for instance — it stays inert and +throws nothing. + +## Usage + +Registered for you by `@editorjs/editorjs`. A headless `Core` opts in: + +```ts +import { DefaultBlockSettingsPlugin } from '@editorjs/default-block-settings-plugin'; + +core.use(DefaultBlockSettingsPlugin); +``` diff --git a/packages/plugins/default-block-settings-plugin/eslint.config.mjs b/packages/plugins/default-block-settings-plugin/eslint.config.mjs new file mode 100644 index 00000000..0434f533 --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/eslint.config.mjs @@ -0,0 +1,39 @@ +import CodeX from 'eslint-config-codex'; + +export default [ + ...CodeX, + { + languageOptions: { + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: import.meta.dirname, + sourceType: 'module', + }, + }, + rules: { + 'n/no-unpublished-import': ['error', { + allowModules: [ + 'eslint-config-codex', + ], + ignoreTypeImport: true, + }], + 'n/no-missing-import': 'off', + '@typescript-eslint/unbound-method': 'off', + 'n/no-unsupported-features/node-builtins': ['error', { + version: '>=24.0.0', + ignores: [], + }], + }, + }, + { + files: ['**/*.spec.ts'], + rules: { + /** + * For test files allow dev dependencies imports + */ + 'n/no-unpublished-import': ['error', { + allowModules: ['@jest/globals', '@editorjs/ui'], + }], + }, + }, +]; diff --git a/packages/plugins/default-block-settings-plugin/jest.config.ts b/packages/plugins/default-block-settings-plugin/jest.config.ts new file mode 100644 index 00000000..8133bf5f --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/jest.config.ts @@ -0,0 +1,31 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +export default { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: [ '/src/**/*.spec.ts' ], + extensionsToTreatAsEsm: [ '.ts' ], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + coverageReporters: ['lcov', 'json-summary', 'text-summary'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + '^.+\\.jsx?$': [ + 'babel-jest', + { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + ], + }, + ], + }, + transformIgnorePatterns: [ + 'node_modules/(?!@editorjs)', + ], +} as JestConfigWithTsJest; diff --git a/packages/plugins/default-block-settings-plugin/package.json b/packages/plugins/default-block-settings-plugin/package.json new file mode 100644 index 00000000..b9add29a --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/package.json @@ -0,0 +1,45 @@ +{ + "name": "@editorjs/default-block-settings-plugin", + "version": "0.0.0", + "packageManager": "yarn@4.0.1", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build:dependencies": "yarn workspaces foreach -Rp --topological-dev --from $npm_package_name --exclude $npm_package_name run build", + "build": "yarn build:dependencies && yarn clear && tsc --build tsconfig.build.json", + "build:declaration": "yarn build --emitDeclarationOnly", + "lint": "eslint ./src", + "lint:ci": "yarn lint --max-warnings 0", + "lint:fix": "yarn lint --fix", + "test": "node --experimental-vm-modules $(yarn bin jest)", + "test:coverage": "yarn test --coverage=true", + "test:mutations": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" stryker run", + "clear": "rm -rf dist && rm -f tsconfig.build.tsbuildinfo && rm -f tsconfig.tsbuildinfo" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@babel/preset-env": "^7.29.2", + "@editorjs/ui": "workspace:^", + "@jest/globals": "^29.7.0", + "@stryker-mutator/core": "^7.0.2", + "@stryker-mutator/jest-runner": "^7.0.2", + "@stryker-mutator/typescript-checker": "^7.0.2", + "@types/eslint": "^9.6.1", + "@types/jest": "^29.5.12", + "@types/node": "^22.10.2", + "babel-jest": "^29.7.0", + "eslint": "^9.24.0", + "eslint-config-codex": "^2.0.3", + "eslint-plugin-import": "^2.31.0", + "jest": "^29.7.0", + "stryker-cli": "^1.0.2", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.5.4" + }, + "dependencies": { + "@codexteam/icons": "^0.3.3", + "@editorjs/sdk": "workspace:^" + } +} diff --git a/packages/plugins/default-block-settings-plugin/src/index.spec.ts b/packages/plugins/default-block-settings-plugin/src/index.spec.ts new file mode 100644 index 00000000..3cd96262 --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/src/index.spec.ts @@ -0,0 +1,412 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { BlockSettingsAPI, BlockSettingsContext, BlockSettingsProvider } from '@editorjs/ui'; +import type { EditorAPI, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; +import { CoreEventType } from '@editorjs/sdk'; +import { DefaultBlockSettingsPlugin } from './index.js'; + +/** + * Params `api.blocks.move` is called with + */ +interface MoveParams { + /** Index the block is moved to */ + toIndex: number; + /** Index the block is moved from */ + fromIndex?: number; +} + +/** + * Params `api.blocks.delete` is called with + */ +interface DeleteParams { + /** Index or id of the block to remove */ + block?: number | string; +} + +/** + * Order the plugin is expected to register at, so its items sit last + */ +const EXPECTED_ORDER = 1000; + +/** + * A popover item as the plugin returns it, narrowed to what the tests read + */ +interface Item { + /** Displayed text */ + title?: string; + /** Machine-readable item name, which reaches the DOM as a data attribute */ + name?: string; + /** Whether activating the item closes the menu */ + closeOnActivate?: boolean; + /** Whether the item is greyed out */ + isDisabled?: boolean; + /** Activation handler, absent on an item with confirmation */ + onActivate?: () => void; + /** Nested params applied once the item is activated */ + confirmation?: { + /** Displayed text of the confirmation */ + title?: string; + /** Machine-readable name of the confirmation item */ + name?: string; + /** Whether activating the confirmation closes the menu */ + closeOnActivate?: boolean; + /** Handler that performs the confirmed action */ + onActivate?: () => void; + }; +} + +/** + * Stands up the plugin over a fake editor whose document is a list of block ids + * @param options - test options + * @param options.blockIds - ids of the blocks the fake document holds + * @param options.withBlockSettings - false to omit the block-settings plugin entirely + */ +function setup({ blockIds = ['b0', 'b1', 'b2'], withBlockSettings = true }: { + /** Ids of the blocks the fake document holds */ + blockIds?: string[]; + /** False to omit the block-settings plugin entirely */ + withBlockSettings?: boolean; +} = {}): { + /** Instance under test */ + plugin: DefaultBlockSettingsPlugin; + /** Fires `core:ready`, which is when the plugin registers */ + ready: () => void; + /** Invokes the registered provider for a block and returns its items */ + itemsFor: (blockId: string) => Promise; + /** The order the provider was registered with */ + registeredOrder: () => number | undefined; + /** Spy on `api.blocks.move` */ + move: jest.Mock<(params: MoveParams) => void>; + /** Spy on `api.blocks.delete` */ + remove: jest.Mock<(params?: DeleteParams) => void>; + /** Mutable list of block ids, so a test can change the document mid-menu */ + blocks: string[]; + } { + const blocks = [...blockIds]; + const move = jest.fn<(params: MoveParams) => void>(); + const remove = jest.fn<(params?: DeleteParams) => void>(); + + let provider: BlockSettingsProvider | undefined; + let order: number | undefined; + + const blockSettings: BlockSettingsAPI = { + register: (registered, options) => { + provider = registered; + order = options?.order; + + return () => { + provider = undefined; + }; + }, + close: () => {}, + }; + + const api = { + blocks: { + getIndexById: (id: string) => blocks.indexOf(id), + getBlocksCount: () => blocks.length, + move, + delete: remove, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention -- the key is the plugin's `name`, which is kebab-case + plugins: withBlockSettings ? { 'block-settings': blockSettings } : {}, + } as unknown as EditorAPI; + + const listeners = new Map void>(); + const eventBus = { + addEventListener: (name: string, listener: () => void) => { + listeners.set(name, listener); + }, + removeEventListener: (name: string, listener: () => void) => { + if (listeners.get(name) === listener) { + listeners.delete(name); + } + }, + dispatchEvent: () => true, + } as unknown as EventBus; + + const plugin = new DefaultBlockSettingsPlugin({ + api, + eventBus, + } as EditorjsPluginParams); + + return { + plugin, + ready: () => listeners.get(`core:${CoreEventType.Ready}`)?.(), + registeredOrder: () => order, + itemsFor: async (blockId: string) => { + if (provider === undefined) { + throw new Error('no provider was registered'); + } + + const context: BlockSettingsContext = { + blockId: blockId as BlockSettingsContext['blockId'], + blockIndex: blocks.indexOf(blockId), + tool: 'paragraph', + }; + + const result = await provider(context); + + if (result === undefined) { + return []; + } + + return (Array.isArray(result) ? result : [result]) as Item[]; + }, + move, + remove, + blocks, + }; +} + +/** + * Finds an item by its title + * @param items - items to search + * @param title - title to look for + */ +function byTitle(items: Item[], title: string): Item { + const item = items.find(candidate => candidate.title === title); + + if (item === undefined) { + throw new Error(`no item titled ${title}`); + } + + return item; +} + +describe('DefaultBlockSettingsPlugin', () => { + let instance: ReturnType; + + beforeEach(() => { + instance = setup(); + }); + + describe('registration', () => { + it('should be named default-block-settings', () => { + expect(DefaultBlockSettingsPlugin.name).toBe('default-block-settings'); + }); + + it('should give every item a stable name, which reaches the DOM as a data attribute', async () => { + instance.ready(); + + const items = await instance.itemsFor('b1'); + + expect(items.map(item => item.name)).toEqual(['move-up', 'move-down', 'delete']); + expect(byTitle(items, 'Delete').confirmation?.name).toBe('delete-confirm'); + }); + + it('should close the menu once an action is taken', async () => { + instance.ready(); + + const items = await instance.itemsFor('b1'); + + expect(byTitle(items, 'Move up').closeOnActivate).toBe(true); + expect(byTitle(items, 'Move down').closeOnActivate).toBe(true); + + /** + * The delete item itself only reveals its confirmation, so it must *not* close; + * the confirmation is what closes the menu + */ + expect(byTitle(items, 'Delete').closeOnActivate).toBeUndefined(); + expect(byTitle(items, 'Delete').confirmation?.closeOnActivate).toBe(true); + }); + + it('should register its provider once the editor is ready', async () => { + instance.ready(); + + const items = await instance.itemsFor('b1'); + + expect(items.map(item => item.title)).toEqual(['Move up', 'Move down', 'Delete']); + }); + + it('should register last, so its items follow other providers', () => { + instance.ready(); + + expect(instance.registeredOrder()).toBe(EXPECTED_ORDER); + }); + + it('should not register before the editor is ready', async () => { + await expect(instance.itemsFor('b1')).rejects.toThrow('no provider was registered'); + }); + + it('should stay inert without a block-settings plugin', () => { + const headless = setup({ withBlockSettings: false }); + + expect(() => headless.ready()).not.toThrow(); + expect(() => headless.plugin.destroy()).not.toThrow(); + }); + + it('should take its provider back out of the menu when destroyed', async () => { + instance.ready(); + instance.plugin.destroy(); + + await expect(instance.itemsFor('b1')).rejects.toThrow('no provider was registered'); + }); + + it('should not register after it has been destroyed', async () => { + instance.plugin.destroy(); + + // The bus outlives the plugin. A plugin destroyed before the editor finished starting + // would otherwise register a provider nothing holds the unregister function for. + instance.ready(); + + await expect(instance.itemsFor('b1')).rejects.toThrow('no provider was registered'); + }); + }); + + describe('moving', () => { + it('should move a block up by one index', async () => { + instance.ready(); + + byTitle(await instance.itemsFor('b1'), 'Move up').onActivate?.(); + + expect(instance.move).toHaveBeenCalledWith({ + fromIndex: 1, + toIndex: 0, + }); + }); + + it('should move a block down by one index', async () => { + instance.ready(); + + byTitle(await instance.itemsFor('b1'), 'Move down').onActivate?.(); + + expect(instance.move).toHaveBeenCalledWith({ + fromIndex: 1, + toIndex: 2, + }); + }); + + it('should disable moving up for the first block', async () => { + instance.ready(); + + const items = await instance.itemsFor('b0'); + + expect(byTitle(items, 'Move up').isDisabled).toBe(true); + expect(byTitle(items, 'Move down').isDisabled).toBe(false); + }); + + it('should disable moving down for the last block', async () => { + instance.ready(); + + const items = await instance.itemsFor('b2'); + + expect(byTitle(items, 'Move down').isDisabled).toBe(true); + expect(byTitle(items, 'Move up').isDisabled).toBe(false); + }); + + it('should resolve the index again at activation, not reuse the one from build time', async () => { + instance.ready(); + + const moveUp = byTitle(await instance.itemsFor('b1'), 'Move up'); + + /** + * A collaborator inserts a block above the target while the menu is open, so `b1` + * is at index 2 by the time the item is activated + */ + instance.blocks.unshift('inserted'); + + moveUp.onActivate?.(); + + expect(instance.move).toHaveBeenCalledWith({ + fromIndex: 2, + toIndex: 1, + }); + }); + + it('should not move the first block up, even with a stale disabled state', async () => { + instance.ready(); + + /** + * Built while `b1` sat in the middle, so `isDisabled` is false -- then a collaborator + * removes the block above it and `b1` becomes first while the menu is still open. + * The greyed-out state is a snapshot, so the guard in the handler is what has to hold + */ + const moveUp = byTitle(await instance.itemsFor('b1'), 'Move up'); + + instance.blocks.splice(0, 1); + + moveUp.onActivate?.(); + + expect(instance.move).not.toHaveBeenCalled(); + }); + + it('should not move the last block down, even with a stale disabled state', async () => { + instance.ready(); + + const moveDown = byTitle(await instance.itemsFor('b1'), 'Move down'); + + instance.blocks.splice(instance.blocks.indexOf('b2'), 1); + + moveDown.onActivate?.(); + + expect(instance.move).not.toHaveBeenCalled(); + }); + + it('should do nothing when the target block is gone', async () => { + instance.ready(); + + const items = await instance.itemsFor('b1'); + + instance.blocks.splice(instance.blocks.indexOf('b1'), 1); + + expect(() => { + byTitle(items, 'Move up').onActivate?.(); + byTitle(items, 'Move down').onActivate?.(); + }).not.toThrow(); + + expect(instance.move).not.toHaveBeenCalled(); + }); + }); + + describe('deleting', () => { + it('should carry a confirmation instead of a direct handler', async () => { + instance.ready(); + + const remove = byTitle(await instance.itemsFor('b1'), 'Delete'); + + expect(remove.onActivate).toBeUndefined(); + expect(remove.confirmation).toBeDefined(); + }); + + it('should label the confirmation as the destructive step', async () => { + instance.ready(); + + const remove = byTitle(await instance.itemsFor('b1'), 'Delete'); + + /** + * The item becomes a different control in place -- same element, new title and new + * action -- so the confirmation needs wording of its own. ui-kit announces this title + * when the state flips, and an empty one would leave a screen reader user with no + * indication that the next activation deletes the block + */ + expect(remove.confirmation?.title).toBe('Click to delete'); + }); + + it('should delete by block id once the confirmation is activated', async () => { + instance.ready(); + + byTitle(await instance.itemsFor('b1'), 'Delete').confirmation?.onActivate?.(); + + expect(instance.remove).toHaveBeenCalledWith({ block: 'b1' }); + }); + + it('should not delete anything until the confirmation is activated', async () => { + instance.ready(); + + await instance.itemsFor('b1'); + + expect(instance.remove).not.toHaveBeenCalled(); + }); + + it('should do nothing when the target block is gone', async () => { + instance.ready(); + + const remove = byTitle(await instance.itemsFor('b1'), 'Delete'); + + instance.blocks.splice(instance.blocks.indexOf('b1'), 1); + + expect(() => remove.confirmation?.onActivate?.()).not.toThrow(); + expect(instance.remove).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/plugins/default-block-settings-plugin/src/index.ts b/packages/plugins/default-block-settings-plugin/src/index.ts new file mode 100644 index 00000000..189060cd --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/src/index.ts @@ -0,0 +1,194 @@ +import type { + BlockId, + EditorAPI, + EditorjsPlugin, + EditorjsPluginParams, + EventBus +} from '@editorjs/sdk'; +import { CoreEventType, PluginType } from '@editorjs/sdk'; +import { IconChevronDown, IconChevronUp, IconCross, IconTrash } from '@codexteam/icons'; +import type { BlockSettingsContext, BlockSettingsMenuConfig } from '@editorjs/ui'; + +/** + * Order the provider registers at, which puts its items after every other provider's. + * The built-in actions belong at the bottom of the menu + */ +const DEFAULT_SETTINGS_ORDER = 1000; + +/** + * Supplies the default entries of the block settings menu -- Move up, Move down and Delete -- + * as an ordinary plugin registered with the `block-settings` API. + * + * Nothing here is privileged: this is the reference example of what used to be a "block tune", + * written the way any third-party plugin would write it. + */ +export class DefaultBlockSettingsPlugin implements EditorjsPlugin { + /** + * Registers with `core.use` under {@link PluginType.Plugin} + */ + public static readonly type = PluginType.Plugin; + + /** + * Identifier the editor registers this plugin under + */ + public static readonly name = 'default-block-settings'; + + /** + * EditorAPI instance used to move and delete blocks + */ + readonly #api: EditorAPI; + + /** + * Event bus the ready listener is attached to + */ + readonly #eventBus: EventBus; + + /** + * Removes the provider again, once it has been registered + */ + #unregister: (() => void) | undefined; + + /** + * DefaultBlockSettingsPlugin class constructor + * @param params - plugin parameters + */ + constructor({ api, eventBus }: EditorjsPluginParams) { + this.#api = api; + this.#eventBus = eventBus; + + this.#eventBus.addEventListener(`core:${CoreEventType.Ready}`, this.#handleReady); + } + + /** + * Takes the provider back out of the menu. + * + * The ready listener goes too: the bus outlives the plugin, and a plugin destroyed before + * the editor finished starting would otherwise register a provider afterwards -- one nothing + * holds the unregister function for + */ + public destroy(): void { + this.#eventBus.removeEventListener(`core:${CoreEventType.Ready}`, this.#handleReady); + + this.#unregister?.(); + this.#unregister = undefined; + } + + /** + * Registers once the editor is ready + */ + #handleReady = (): void => { + this.#register(); + }; + + /** + * Registers the provider, unless the editor runs without a block settings menu. + * + * The lookup is deliberately late and optional: `api.plugins` is per editor instance, so a + * headless `Core` that never registered `BlockSettingsUI` simply has no entry here and this + * plugin stays inert rather than failing to initialise + */ + #register(): void { + const blockSettings = this.#api.plugins['block-settings']; + + if (blockSettings === undefined) { + return; + } + + this.#unregister = blockSettings.register( + context => this.#items(context), + { order: DEFAULT_SETTINGS_ORDER } + ); + } + + /** + * Builds the items for one block + * @param context - the block the menu is being built for + */ + #items({ blockId, blockIndex }: BlockSettingsContext): BlockSettingsMenuConfig { + const lastIndex = this.#api.blocks.getBlocksCount() - 1; + + return [ + { + name: 'move-up', + title: 'Move up', + icon: IconChevronUp, + closeOnActivate: true, + + /** + * A snapshot from when the menu was built, unlike the action below. Going stale + * only greys an item out, while a stale index would move the wrong block + */ + isDisabled: blockIndex <= 0, + onActivate: () => this.#move(blockId, -1), + }, + { + name: 'move-down', + title: 'Move down', + icon: IconChevronDown, + closeOnActivate: true, + isDisabled: blockIndex >= lastIndex, + onActivate: () => this.#move(blockId, 1), + }, + { + name: 'delete', + title: 'Delete', + icon: IconTrash, + + /** + * Deletion asks first. The item turns into its own confirmation, and only that + * confirmation carries the handler -- which is why this item has no `onActivate` + */ + confirmation: { + name: 'delete-confirm', + title: 'Click to delete', + icon: IconCross, + closeOnActivate: true, + onActivate: () => this.#delete(blockId), + }, + }, + ]; + } + + /** + * Moves the block one position in the given direction. + * + * The position is resolved from the id now rather than taken from the context: the menu may + * have been open while a collaborator inserted a block above the target, or while an undo ran + * @param blockId - id of the block to move + * @param offset - -1 to move up, 1 to move down + */ + #move(blockId: BlockId, offset: number): void { + const fromIndex = this.#api.blocks.getIndexById(blockId); + + /** + * The block is gone -- removed by a collaborator or an undo while the menu was open. + * The lookup reports -1 rather than throwing, so there is nothing to recover from + */ + if (fromIndex === -1) { + return; + } + + const toIndex = fromIndex + offset; + + if (toIndex < 0 || toIndex > this.#api.blocks.getBlocksCount() - 1) { + return; + } + + this.#api.blocks.move({ + fromIndex, + toIndex, + }); + } + + /** + * Deletes the block, by id rather than by position + * @param blockId - id of the block to delete + */ + #delete(blockId: BlockId): void { + if (this.#api.blocks.getIndexById(blockId) === -1) { + return; + } + + this.#api.blocks.delete({ block: blockId }); + } +} diff --git a/packages/plugins/default-block-settings-plugin/stryker.conf.mjs b/packages/plugins/default-block-settings-plugin/stryker.conf.mjs new file mode 100644 index 00000000..9b1f4ca1 --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/stryker.conf.mjs @@ -0,0 +1,35 @@ +// @ts-check +/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */ +const config = { + _comment: + "This config was generated using 'stryker init'. Please take a look at: https://stryker-mutator.io/docs/stryker-js/configuration/ for more information.", + packageManager: "yarn", + thresholds: { + break: 75, + }, + thresholds_comment: "Minimum required coverage. Increase once we're closer to 100%.", + clearTextReporter: { + allowEmojis: true, + }, + reporters: [ + "json", + "html", + "clear-text", + "progress", + "dashboard", + ], + testRunner: "jest", + testRunner_comment: + "Take a look at https://stryker-mutator.io/docs/stryker-js/jest-runner for information about the jest plugin.", + coverageAnalysis: "perTest", + tsconfigFile: "tsconfig.json", + checkers: ["typescript"], + timeoutMS: 10000, + mutate: ["./src/**/*.ts", "!./src/**/mocks/*.ts", "!./src/**/__mocks__/*.ts", "!./src/**/*.spec.ts"], + /* + * In some cases PRs might not have any unit-tests + */ + allowEmpty: true, +}; + +export default config; diff --git a/packages/plugins/default-block-settings-plugin/tsconfig.build.json b/packages/plugins/default-block-settings-plugin/tsconfig.build.json new file mode 100644 index 00000000..5b7ae0fc --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "node_modules/**/*", + "dist/**/*", + "**/*.spec.ts" + ], + "references": [ + { + "path": "../../sdk/tsconfig.build.json" + } + ] +} diff --git a/packages/plugins/default-block-settings-plugin/tsconfig.eslint.json b/packages/plugins/default-block-settings-plugin/tsconfig.eslint.json new file mode 100644 index 00000000..72ee87bc --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/tsconfig.eslint.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "types": ["node", "jest"] + }, + "extends": "./tsconfig.json", + "include": [ + "src/**/*", + "eslint.config.mjs", + "**/*.spec.ts", + "jest.config.ts", + "stryker.conf.mjs" + ], + "exclude": [ + "dist/**/*", + "node_modules/**/*" + ] +} diff --git a/packages/plugins/default-block-settings-plugin/tsconfig.json b/packages/plugins/default-block-settings-plugin/tsconfig.json new file mode 100644 index 00000000..8cc9fa49 --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "composite": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules/**/*", + "dist/**/*" + ], + "references": [ + { + "path": "../../sdk/tsconfig.build.json" + } + ] +} diff --git a/packages/plugins/shortcuts-plugin/src/index.ts b/packages/plugins/shortcuts-plugin/src/index.ts index a222cea3..653c7708 100644 --- a/packages/plugins/shortcuts-plugin/src/index.ts +++ b/packages/plugins/shortcuts-plugin/src/index.ts @@ -1,6 +1,5 @@ import type { BlockToolFacade, - BlockTuneFacade, EditorAPI, EditorjsPlugin, EditorjsPluginParams, @@ -207,14 +206,4 @@ export class ShortcutsPlugin implements EditorjsPlugin<'shortcuts'> { * @todo `shortcuts` map for block tools (e.g. list styles) — apply render/data overrides */ } - - /** - * Block tune shortcuts — reserved for future use. - * @param _tool - loaded block tune facade - */ - #processBlockTune(_tool: BlockTuneFacade): void { - /* - * @todo block tune shortcuts if needed - */ - } } diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 65b4c78e..24f9364b 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -3,9 +3,11 @@ import type { BlockId, BlockNodeInit, DocumentData, + PluginDataSerialized, TextNodeSerialized, ValueSerialized } from '@editorjs/model-types'; +import type { PluginDataFor, PluginId } from '../index.js'; /** * Blocks API interface @@ -22,6 +24,7 @@ export interface BlocksAPI { * @param [params.focus] - flag indicates if new block should be focused @todo implement * @param [params.replace] - flag indicates if block at index should be replaced @todo implement * @param [params.id] - id of the inserted block @todo implement + * @param [params.plugins] - initial per-plugin data for the block, keyed by plugin name */ insert(params?: { /** Block tool name to insert */ @@ -36,6 +39,8 @@ export interface BlocksAPI { replace?: boolean; /** Id of the inserted block */ id?: string; + /** Initial per-plugin data for the block, keyed by plugin name */ + plugins?: Record; /** User id. Defaults to the current user id from the config */ userId?: string | number; }): void; @@ -149,6 +154,16 @@ export interface BlocksAPI { */ getIdByIndex(index: number): BlockId | undefined; + /** + * Returns the name of the tool rendering the block at the given index, or undefined when no + * block is there. + * + * Exists so that a caller needing one scalar about one block is not pushed to + * `api.document.data`, which serializes the entire document to get there + * @param index - index of the block + */ + getToolByIndex(index: number): string | undefined; + /** * Returns serialized data for provided data key * @param params - getData parameters @@ -216,6 +231,43 @@ export interface BlocksAPI { userId?: string | number; }): void; + /** + * Returns the per-block data stored by the given plugin, or undefined when it stores none. + * + * The data type is resolved from `EditorjsPluginDataMap` for a plugin that declares its shape, + * and falls back to a plain record otherwise. + * @param params - getPluginData parameters + * @param params.block - index or id of the block + * @param params.plugin - name the data is stored under, by convention the plugin's `name` + */ + getPluginData(params: { + /** Index or id of the block */ + block: number | string; + /** Name the data is stored under */ + plugin: Id; + }): PluginDataFor | undefined; + + /** + * Merges the passed keys into the per-block data of the given plugin, creating the entry when + * the block has none yet; a key set to `undefined` is removed. Each key is recorded as its own + * modification, so writing several keys is not a single undo step. + * @param params - updatePluginData parameters + * @param params.block - index or id of the block + * @param params.plugin - name the data is stored under, by convention the plugin's `name` + * @param params.data - keys to merge into the plugin's data + * @param [params.userId] - user id. Defaults to the current user id from the config + */ + updatePluginData(params: { + /** Index or id of the block */ + block: number | string; + /** Name the data is stored under */ + plugin: Id; + /** Keys to merge into the plugin's data */ + data: Partial>; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; + }): void; + /** * Splits the block at the given data key and character offset. * If the tool supports splitting (canBeSplit = true) a new block of the same type is inserted after the current one. @@ -243,9 +295,8 @@ export interface BlocksAPI { * Updates block data by id * @param id - id of the block to update * @param data - (optional) the new data. Can be partial. - * @param tunes - (optional) tune data */ - // update(id: string, data?: Partial, tunes?: { [name: string]: BlockTuneData }): Promise; + // update(id: string, data?: Partial): Promise; /** * Converts block to another type. Both blocks should provide the conversionConfig. diff --git a/packages/sdk/src/entities/BaseTool.ts b/packages/sdk/src/entities/BaseTool.ts index 83e34e72..e3f00f55 100644 --- a/packages/sdk/src/entities/BaseTool.ts +++ b/packages/sdk/src/entities/BaseTool.ts @@ -1,6 +1,5 @@ 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'; @@ -75,13 +74,13 @@ export type ToolOptionsFactory< > = (config: Config) => Options; // Re-export so consumers can import all option types from this file -export type { BlockToolOptions, InlineToolOptions, BlockTuneOptions }; +export type { BlockToolOptions, InlineToolOptions }; /** * Union of all per-tool option shapes. * Used as the type of the second argument of `core.use(Tool, options)`. */ -export type ToolStaticOptions = BlockToolOptions | InlineToolOptions | BlockTuneOptions; +export type ToolStaticOptions = BlockToolOptions | InlineToolOptions; /** * Maps a {@link ToolType} value to its corresponding tool options interface. @@ -97,10 +96,6 @@ export type ToolTypeToOptions = { * InlineTool Options */ [ToolType.Inline]: InlineToolOptions; - /** - * BlockTune Options - */ - [ToolType.Tune]: BlockTuneOptions; }; /** diff --git a/packages/sdk/src/entities/BlockTool.ts b/packages/sdk/src/entities/BlockTool.ts index 1f77f69c..8cc8ad9e 100644 --- a/packages/sdk/src/entities/BlockTool.ts +++ b/packages/sdk/src/entities/BlockTool.ts @@ -41,8 +41,6 @@ export enum BlockToolOptionKey { Shortcut = 'shortcut', /** Inline tools enabled for blocks of this type. */ InlineToolbar = 'inlineToolbar', - /** Block tunes enabled for blocks of this type. */ - Tunes = 'tunes', /** Conversion configuration for this tool. */ ConversionConfig = 'conversionConfig', /** Whether the block can be split into multiple blocks of its type. */ @@ -77,12 +75,6 @@ export interface BlockToolOptions - extends BaseToolOptions { - /** Any additional custom options exposed by the tune developer. */ - [key: string]: unknown; -} - -/** - * BlockTune data type - * @todo move to data description section when migrated from V2 - */ -export type BlockTuneData = any; - -/** - * Extended BlockToolConstructorOptions interface for version 3. - */ -export interface BlockTuneConstructorOptions< - /** - * Data structure describing the tool's input/output data - */ - Data extends BlockTuneData = BlockTuneData, - /** - * User-end configuration for the tool - */ - Config extends ToolConfig = ToolConfig -> { - /** - * Tool's input/output data - */ - data: Data; - - /** - * Config could be passed by tools user through the Editor config - */ - config: Config; -} - -/** - * Block Tune interface for version 3 - * @todo describe the interface when the adapter implementation is done - */ -export type BlockTune< - /** - * Data structure describing the tool's input/output data - * - * any is used as a placeholder to allow using BlockToolData without generic - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - Data extends BlockTuneData = any, - /** - * User-end configuration for the tool - * - * any is used as a placeholder to allow using BlockToolData without generic - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - Config extends ToolConfig = any -> = Omit; - -/** - * Block Tune constructor class - */ -export interface BlockTuneConstructor extends BaseToolConstructor { - new(options: BlockTuneConstructorOptions): BlockTune; - - /** - * Property specifies that the class is a Tune - */ - type: ToolType.Tune; -}; diff --git a/packages/sdk/src/entities/EntityType.ts b/packages/sdk/src/entities/EntityType.ts index 02c8a2e0..990e6da5 100644 --- a/packages/sdk/src/entities/EntityType.ts +++ b/packages/sdk/src/entities/EntityType.ts @@ -25,7 +25,12 @@ export enum UiComponentType { /** * Toolbar area wrapper. Includes Toolbox and Block Settings */ - Toolbar = 'toolbar' + Toolbar = 'toolbar', + + /** + * Per-block settings menu wrapper + */ + BlockSettings = 'block-settings' } /** @@ -40,12 +45,7 @@ export enum ToolType { /** * Inline Tool */ - Inline = 'inline', - - /** - * Block Tune - */ - Tune = 'tune' + Inline = 'inline' } /** diff --git a/packages/sdk/src/entities/MenuConfig.ts b/packages/sdk/src/entities/MenuConfig.ts index db7ea4a2..be487a4c 100644 --- a/packages/sdk/src/entities/MenuConfig.ts +++ b/packages/sdk/src/entities/MenuConfig.ts @@ -11,7 +11,7 @@ export { PopoverItemType }; /** * Menu configuration format. - * Is used for defining Block Tunes Menu items via Block Tool's renderSettings(), Block Tune's render() and Inline Tool's render(). + * Is used for defining menu items via Block Tool's renderSettings() and Inline Tool's render(). */ export type MenuConfig = MenuConfigItem | MenuConfigItem[]; diff --git a/packages/sdk/src/entities/index.ts b/packages/sdk/src/entities/index.ts index 7bbfed56..eef4535d 100644 --- a/packages/sdk/src/entities/index.ts +++ b/packages/sdk/src/entities/index.ts @@ -1,7 +1,6 @@ export * from './BaseTool.js'; export type * from './InlineTool.js'; export * from './BlockTool.js'; -export type * from './BlockTune.js'; export type * from './Config.js'; export * from './BlockToolAdapter.js'; export * from './EventBus/index.js'; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 31b3de42..244e60f2 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -32,6 +32,32 @@ export interface EditorjsPluginApiMap {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled in by plugin packages via module augmentation export interface ToolPluginOptionsMap {} +/** + * Maps a plugin's `name` to the shape of the per-block data that plugin stores in the Model, + * read and written through `api.blocks.getPluginData` / `updatePluginData`. + * + * Augmented by plugin packages the same way as {@link EditorjsPluginApiMap}. + * @example + * declare module '@editorjs/sdk' { + * interface EditorjsPluginDataMap { + * anchors: { id: string }; + * } + * } + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled in by plugin packages via module augmentation +export interface EditorjsPluginDataMap {} + +/** + * Shape of the per-block data stored by the plugin with the given id, falling back to a plain + * record for an id absent from {@link EditorjsPluginDataMap} — per-block data is often written by + * a plugin with no reason to publish its shape. The check is non-distributive + * (`[Id] extends [...]`) because the default {@link PluginId} includes `string & {}`, and a + * distributive conditional would collapse a declared id onto the fallback. + */ +export type PluginDataFor = [Id] extends [keyof EditorjsPluginDataMap] + ? EditorjsPluginDataMap[Id] + : Record; + /** * Identifier of a plugin — the value of its static `name`. * @@ -39,7 +65,7 @@ export interface ToolPluginOptionsMap {} * 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 & {}); +export type PluginId = keyof EditorjsPluginApiMap | keyof ToolPluginOptionsMap | keyof EditorjsPluginDataMap | (string & {}); /** * Registry of plugin public APIs, exposed as `api.plugins`. @@ -69,8 +95,8 @@ export type { BlockNodeInit, BlockNodeSerialized, BlockToolName, - BlockTuneName, - BlockTuneSerialized, + PluginDataName, + PluginDataSerialized, TextRange, InlineFragment, InlineTreeNodeSerialized, @@ -102,7 +128,7 @@ export { generateBlockId, createBlockToolName, createDataKey, - createBlockTuneName, + createPluginDataName, createInlineToolName, createInlineToolData, EventAction, @@ -122,13 +148,13 @@ export { BlockAddedEvent, BlockRemovedEvent, PropertyModifiedEvent, - TuneModifiedEvent, + PluginDataModifiedEvent, IndexBase, IndexKind, BlockIndex, DataIndex, TextIndex, - TuneIndex, + PluginDataIndex, DocumentIndex, PropertyIndex, PartialIndex diff --git a/packages/sdk/src/pluginTypeMaps.spec.ts b/packages/sdk/src/pluginTypeMaps.spec.ts index fa740c96..a15319f3 100644 --- a/packages/sdk/src/pluginTypeMaps.spec.ts +++ b/packages/sdk/src/pluginTypeMaps.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable jsdoc/require-jsdoc,@typescript-eslint/no-magic-numbers */ import { describe, expect, it } from '@jest/globals'; -import type { PluginsAPI, ToolPluginOptions } from './index.js'; +import type { PluginDataFor, PluginsAPI, ToolPluginOptions } from './index.js'; /** * Public API a fake plugin exposes — stands in for a real plugin package's API type @@ -24,6 +24,16 @@ interface ProbeToolOptions { level: number; } +/** + * Per-block data the fake plugin stores in the Model + */ +interface ProbePluginData { + /** + * Arbitrary key used to check the data shape is type-checked + */ + id: string; +} + /** * 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 @@ -42,6 +52,13 @@ declare module './index.js' { */ probe: ProbeToolOptions; } + + interface EditorjsPluginDataMap { + /** + * Fake plugin's per-block data + */ + probe: ProbePluginData; + } } describe('Plugin type maps', () => { @@ -79,6 +96,29 @@ describe('Plugin type maps', () => { }); }); + describe('EditorjsPluginDataMap', () => { + it('should resolve an augmented id to the declared data shape', () => { + const data: PluginDataFor<'probe'> = { id: 'intro' }; + + const typed: ProbePluginData = data; + + expect(typed.id).toBe('intro'); + }); + + it('should reject data that does not match the declared shape', () => { + // @ts-expect-error -- `id` is a string + const data: PluginDataFor<'probe'> = { id: 42 }; + + expect(data).toBeDefined(); + }); + + it('should fall back to a plain record for an id no package has augmented', () => { + const data: PluginDataFor<'unknownPlugin'> = { anything: true }; + + expect(data.anything).toBe(true); + }); + }); + describe('ToolPluginOptionsMap', () => { it('should type an augmented key as the tool-directed options', () => { const options: ToolPluginOptions = { diff --git a/packages/sdk/src/tools/ToolsCollection.spec.ts b/packages/sdk/src/tools/ToolsCollection.spec.ts new file mode 100644 index 00000000..63f39c05 --- /dev/null +++ b/packages/sdk/src/tools/ToolsCollection.spec.ts @@ -0,0 +1,63 @@ +/* eslint-disable jsdoc/require-jsdoc -- inline test stubs */ + +import { describe, expect, it } from '@jest/globals'; +import { ToolType } from '../entities/index.js'; +import type { ToolFacadeClass } from './facades/index.js'; +import { ToolsCollection } from './ToolsCollection.js'; + +/** + * Stands in for a registered facade: `ToolsCollection` filters purely on the kind + * predicates, so a bare object carrying `type` and those predicates is enough. + * @param name - name the facade is registered under + * @param type - kind the facade reports + */ +function facadeStub(name: string, type: ToolType): [string, ToolFacadeClass] { + const facade = { + name, + type, + isBlock: () => type === ToolType.Block, + isInline: () => type === ToolType.Inline, + }; + + return [name, facade as unknown as ToolFacadeClass]; +} + +describe('ToolsCollection', () => { + const collection = new ToolsCollection([ + facadeStub('paragraph', ToolType.Block), + facadeStub('header', ToolType.Block), + facadeStub('bold', ToolType.Inline), + ]); + + it('should return only block tool facades from blockTools', () => { + expect([...collection.blockTools.keys()]).toEqual(['paragraph', 'header']); + }); + + it('should return only inline tool facades from inlineTools', () => { + expect([...collection.inlineTools.keys()]).toEqual(['bold']); + }); + + it('should expose no view beyond blockTools and inlineTools', () => { + /* + * Own getters live on the prototype, so the collection's kind views are read from + * there rather than from the instance. + */ + const views = Object + .entries(Object.getOwnPropertyDescriptors(ToolsCollection.prototype)) + .filter(([, descriptor]) => descriptor.get !== undefined) + .map(([key]) => key); + + expect(views.sort()).toEqual(['blockTools', 'inlineTools']); + }); + + it('should no longer expose a blockTunes view', () => { + type CollectionViews = { + // @ts-expect-error -- `blockTunes` went with the tune tool kind + blockTunes: ToolsCollection['blockTunes']; + }; + + const probe: CollectionViews | undefined = undefined; + + expect(probe).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/tools/ToolsCollection.ts b/packages/sdk/src/tools/ToolsCollection.ts index 979a91e9..1ed3c86d 100644 --- a/packages/sdk/src/tools/ToolsCollection.ts +++ b/packages/sdk/src/tools/ToolsCollection.ts @@ -1,6 +1,5 @@ import { type BlockToolFacade } from './facades/BlockToolFacade.js'; import { type InlineToolFacade } from './facades/InlineToolFacade.js'; -import { type BlockTuneFacade } from './facades/BlockTuneFacade.js'; import type { ToolFacadeClass } from './facades/index.js'; /** @@ -28,15 +27,4 @@ export class ToolsCollection extend return new ToolsCollection(tools); } - - /** - * Returns Block Tunes collection - */ - public get blockTunes(): ToolsCollection { - const tools = Array - .from(this.entries()) - .filter(([, tool]) => tool.isTune()) as [string, BlockTuneFacade][]; - - return new ToolsCollection(tools); - } } diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts index c0ad6635..e05afb97 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts @@ -1,11 +1,33 @@ /* eslint-disable jsdoc/require-jsdoc,@typescript-eslint/no-magic-numbers */ import { describe, expect, it } from '@jest/globals'; -import type { BlockToolConstructor, BlockToolData } from '../../entities/index.js'; +import type { + BlockToolConstructor, + BlockToolData, + /* + * Everything below is asserted absent: under `ts-jest` a stale `@ts-expect-error` is + * itself a diagnostic, so each directive fails this suite while its name still exists. + * Only tool-kind names are enumerated — the model layer keeps its own per-block data + * vocabulary, renamed separately by `plugin-block-data`. + */ + // @ts-expect-error -- the `BlockTune` contract went with the tune tool kind + BlockTune, + // @ts-expect-error -- the `BlockTuneConstructor` contract went with the tune tool kind + BlockTuneConstructor, + // @ts-expect-error -- the `BlockTuneConstructorOptions` contract went with the tune tool kind + BlockTuneConstructorOptions, + // @ts-expect-error -- the `BlockTuneOptions` contract went with the tune tool kind + BlockTuneOptions, + // @ts-expect-error -- the `BlockTuneData` contract went with the tune tool kind + BlockTuneData +} from '../../entities/index.js'; import { BlockToolOptionKey, ToolType } from '../../entities/index.js'; import type { ToolOptions } from './BaseToolFacade.js'; import { UserToolOptions } from './BaseToolFacade.js'; import { BlockToolFacade } from './BlockToolFacade.js'; +// @ts-expect-error -- the `BlockTuneFacade` class went with the tune tool kind +import type { BlockTuneFacade } from './index.js'; +import type { BaseToolFacade } from './BaseToolFacade.js'; import type { InlineFragment } from '@editorjs/model-types'; import { BlockChildType, NODE_TYPE_HIDDEN_PROP } from '@editorjs/model-types'; import type { EditorAPI } from '../../api'; @@ -507,3 +529,59 @@ describe('BaseToolFacade (via BlockToolFacade)', () => { }); }); }); + +describe('Tune tool kind removal', () => { + it('should no longer export the tune tool contracts', () => { + /** + * References the absent names so the import directives above are the assertion, + * rather than unused imports the linter would strip. + */ + type ToolContracts = { + tune: BlockTune; + constructor: BlockTuneConstructor; + constructorOptions: BlockTuneConstructorOptions; + options: BlockTuneOptions; + data: BlockTuneData; + facade: BlockTuneFacade; + }; + + const probe: ToolContracts | undefined = undefined; + + expect(probe).toBeUndefined(); + }); + + it('should no longer define a Tune member on ToolType', () => { + // @ts-expect-error -- `Tune` was removed from the ToolType enum + expect(ToolType.Tune).toBeUndefined(); + }); + + it('should no longer define a Tunes member on BlockToolOptionKey', () => { + /* + * `BlockToolOptions['tunes']` itself cannot be asserted: the interface carries an + * open `[key: string]: unknown` index signature for tool-author options, so every + * key resolves. The enum member is the removable surface. + */ + // @ts-expect-error -- `Tunes` was removed from the BlockToolOptionKey enum + expect(BlockToolOptionKey.Tunes).toBeUndefined(); + }); + + it('should no longer define an EnabledBlockTunes member on UserToolOptions', () => { + // @ts-expect-error -- `EnabledBlockTunes` was removed from the UserToolOptions enum + expect(UserToolOptions.EnabledBlockTunes).toBeUndefined(); + }); + + it('should no longer expose tune members on the facades', () => { + type FacadeMembers = { + // @ts-expect-error -- `isTune()` went with the tune tool kind + isTune: BaseToolFacade['isTune']; + // @ts-expect-error -- `enabledBlockTunes` went with the tune tool option + enabledBlockTunes: BlockToolFacade['enabledBlockTunes']; + // @ts-expect-error -- the per-tool `tunes` collection went with the tune tool kind + tunes: BlockToolFacade['tunes']; + }; + + const probe: FacadeMembers | undefined = undefined; + + expect(probe).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.ts b/packages/sdk/src/tools/facades/BaseToolFacade.ts index 136ad777..0751c62e 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.ts @@ -5,17 +5,16 @@ import { isFunction } from '@editorjs/helpers'; import { type BlockToolFacade } from './BlockToolFacade.js'; import { type InlineToolFacade } from './InlineToolFacade.js'; 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, + BlockTool, BlockToolConstructor, InlineTool, InlineToolConstructor, + ToolTypeToOptions, ToolStaticOptions, BlockToolOptions, InlineToolOptions, ToolOptionsFactory } 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; +export type ToolConstructable = BlockToolConstructor | InlineToolConstructor; // Re-export canonical option-key enums so facades/consumers can import from one place export { BaseToolOptionKey } from '../../entities/BaseTool.js'; @@ -31,8 +30,6 @@ export enum UserToolOptions { Toolbox = 'toolbox', /** Inline tools enabled for blocks of this type. */ EnabledInlineTools = 'inlineToolbar', - /** Block tunes enabled for blocks of this type. */ - EnabledBlockTunes = 'tunes', /** Plugin-specific configuration. */ Config = 'config' } @@ -40,7 +37,7 @@ export enum UserToolOptions { export type ToolOptions = ToolStaticOptions; // Re-export per-tool option types so consumers can import them from here -export type { BlockToolOptions, InlineToolOptions, BlockTuneOptions }; +export type { BlockToolOptions, InlineToolOptions }; /** * BlockToolFacade constructor options inteface @@ -122,7 +119,7 @@ export abstract class BaseToolFacade { */ public inlineTools: ToolsCollection = new ToolsCollection(); - /** - * BlockTune collection for current Block Tool - */ - public tunes: ToolsCollection = new ToolsCollection(); - /** * Tool's constructable blueprint — narrowed to BlockToolConstructor */ @@ -224,13 +218,6 @@ export class BlockToolFacade extends BaseToolFacade { return this.useToolOptions[UserToolOptions.EnabledInlineTools] ?? false; } - /** - * Returns enabled tunes for Tool - */ - public get enabledBlockTunes(): boolean | string[] { - return this.useToolOptions[UserToolOptions.EnabledBlockTunes] ?? false; - } - // /** // * Returns Tool paste configuration // */ @@ -239,7 +226,7 @@ export class BlockToolFacade extends BaseToolFacade { // } /** - * Returns sanitize configuration for Block Tool including configs from related Inline Tools and Block Tunes + * Returns sanitize configuration for Block Tool including configs from related Inline Tools */ // @cacheable // public get sanitizeConfig(): SanitizerConfig { diff --git a/packages/sdk/src/tools/facades/BlockTuneFacade.ts b/packages/sdk/src/tools/facades/BlockTuneFacade.ts deleted file mode 100644 index ff92ecca..00000000 --- a/packages/sdk/src/tools/facades/BlockTuneFacade.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BaseToolFacade } from './BaseToolFacade.js'; -import type { BlockAPI } from 'editorjs-v2'; -import type { BlockTuneConstructor, BlockTune as IBlockTune, BlockTuneData } from '../../entities'; -import { ToolType } from '../../entities'; -// import type { BlockTuneData } from 'editorjs-v2'; - -/** - * Stub class for BlockTunes - * @todo Implement - */ -export class BlockTuneFacade extends BaseToolFacade { - /** - * Tool type — Tune - */ - public type: ToolType.Tune = ToolType.Tune; - - /** - * Tool's constructable blueprint - */ - protected declare constructable: BlockTuneConstructor; - - /** - * Constructs new BlockTune instance from constructable - * @param data - Tunes data - * @param _block - Block API object - */ - public create(data: BlockTuneData, _block: BlockAPI): IBlockTune { - return new this.constructable({ - // api: this.api, - config: this.config, - // block, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - data, - }); - } -} diff --git a/packages/sdk/src/tools/facades/index.ts b/packages/sdk/src/tools/facades/index.ts index d7c388cc..8ad5034c 100644 --- a/packages/sdk/src/tools/facades/index.ts +++ b/packages/sdk/src/tools/facades/index.ts @@ -1,10 +1,8 @@ import type { BlockToolFacade } from './BlockToolFacade.js'; import type { InlineToolFacade } from './InlineToolFacade.js'; -import type { BlockTuneFacade } from './BlockTuneFacade.js'; -export type ToolFacadeClass = BlockToolFacade | InlineToolFacade | BlockTuneFacade; +export type ToolFacadeClass = BlockToolFacade | InlineToolFacade; export * from './BaseToolFacade.js'; export * from './BlockToolFacade.js'; -export * from './BlockTuneFacade.js'; export * from './InlineToolFacade.js'; diff --git a/packages/ui/eslint.config.mjs b/packages/ui/eslint.config.mjs index 1063eee3..0a6bb039 100644 --- a/packages/ui/eslint.config.mjs +++ b/packages/ui/eslint.config.mjs @@ -18,6 +18,7 @@ export default [ 'n/no-unpublished-import': ['error', { allowModules: [ 'eslint-config-codex', + '@jest/globals', ], ignoreTypeImport: true, }], @@ -32,4 +33,15 @@ export default [ }], }, }, + { + files: ['**/*.spec.ts'], + rules: { + /** + * For test files allow dev dependencies imports + */ + 'n/no-unpublished-import': ['error', { + allowModules: ['@jest/globals'], + }], + }, + }, ]; diff --git a/packages/ui/jest.config.ts b/packages/ui/jest.config.ts index da221cac..6f8210ab 100644 --- a/packages/ui/jest.config.ts +++ b/packages/ui/jest.config.ts @@ -2,11 +2,23 @@ import type { JestConfigWithTsJest } from 'ts-jest'; export default { preset: 'ts-jest', - testEnvironment: 'node', + + /** + * Unlike every other package in the workspace, `ui` renders DOM. The teardown suites get by + * with fake elements, but anything asserting on real markup or a document selection needs a + * browser-like environment, which is why `jest-environment-jsdom` is an explicit dependency. + */ + testEnvironment: 'jsdom', testMatch: [ '/src/**/*.spec.ts' ], extensionsToTreatAsEsm: [ '.ts' ], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', + + /** + * CSS Modules hand back the class name itself, so an assertion on rendered markup reads as + * the class the source asked for. `@codexteam/ui`'s themes are imported for their side + * effects only, and Jest cannot parse the CSS behind them either. + */ '\\.pcss$': '/test/style.cjs', '^@codexteam/ui/styles(?:/.*)?$': '/test/style.cjs', }, @@ -18,5 +30,21 @@ export default { useESM: true, }, ], + '^.+\\.jsx?$': [ + 'babel-jest', + { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + ], + }, + ], }, + + /** + * `@codexteam/*` joins `@editorjs/*` here: `@editorjs/ui-kit` is shipped as ESM and is + * imported directly by the components under test. + */ + transformIgnorePatterns: [ + 'node_modules/(?!@editorjs|@codexteam)', + ], } as JestConfigWithTsJest; diff --git a/packages/ui/package.json b/packages/ui/package.json index 3bf8e34f..43803b54 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,22 +18,27 @@ "dist" ], "scripts": { - "build": "vite build", - "build:declaration": "", + "build:dependencies": "yarn workspaces foreach -Rp --topological-dev --from $npm_package_name --exclude $npm_package_name run build", + "build": "yarn build:dependencies && vite build", + "build:declaration": "yarn build", "dev": "chokidar 'src/**/*' -c 'yarn build' --initial", "prebuild": "yarn clear", "lint": "eslint ./src", "lint:ci": "yarn lint --max-warnings 0", "lint:fix": "yarn lint --fix", "test": "node --experimental-vm-modules $(yarn bin jest)", + "test:coverage": "yarn test --coverage=true", "clear": "rm -rf ./dist && rm -f tsconfig.tsbuildinfo && rm -f tsconfig.tsbuildinfo.json" }, "devDependencies": { + "@babel/core": "^7.29.0", + "@babel/preset-env": "^7.29.2", "@editorjs/core": "workspace:^", "@editorjs/dom-adapters": "workspace:^", "@editorjs/paragraph": "workspace:^", "@jest/globals": "^29.7.0", "@types/jest": "^29.5.12", + "babel-jest": "^29.7.0", "chokidar-cli": "^3.0.0", "eslint": "^9.9.1", "eslint-config-codex": "^2.0.3", diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts new file mode 100644 index 00000000..57feae38 --- /dev/null +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -0,0 +1,1195 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { CoreConfigValidated, EditorAPI } from '@editorjs/sdk'; +import { EventBus } from '@editorjs/sdk'; +import { BlockSettingsUI } from './BlockSettings.js'; +import { BlockSettingsOpenUIEvent } from './events/index.js'; +import type { BlockSettingsRenderedUIEventPayload } from './events/index.js'; +import { BlockSelectedUIEvent } from '../Blocks/events/index.js'; +import { ToolboxClosedUIEvent, ToolboxOpenedUIEvent } from '../Toolbox/events/index.js'; +import type { BlockSettingsProvider } from './BlockSettings.js'; + +/** + * Blocks the fake document is made of + */ +interface StubBlock { + /** Block id */ + id: string; + /** Tool name */ + name: string; +} + +const defaultBlocks: StubBlock[] = [ + { id: 'block-0', + name: 'paragraph' }, + { id: 'block-1', + name: 'paragraph' }, + { id: 'block-2', + name: 'image' }, +]; + +/** + * A position no block occupies, so `getIdByIndex` resolves to nothing + */ +const MISSING_BLOCK_INDEX = 99; + +/** How long the deliberately slow provider takes, so a stale build would land last. */ +const SLOW_PROVIDER_MS = 20; + +/** Long enough for both overlapping requests to have settled. */ +const BOTH_REQUESTS_SETTLED_MS = 50; + +/** Well past whatever the provider timeout is set to, advanced over on fake timers. */ +const PAST_PROVIDER_TIMEOUT_MS = 10_000; + +/** + * Lets the queued provider promises settle. Opening is asynchronous because a provider may + * return a promise, so nothing about the rendered menu can be asserted synchronously + */ +async function flush(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)); +} + +/** + * Builds a plugin instance over a fake editor + * @param blocks - blocks the fake document consists of + */ +function setup(blocks: StubBlock[] = defaultBlocks): { + /** Instance under test */ + plugin: BlockSettingsUI; + /** Event bus the instance is wired to */ + eventBus: EventBus; + /** Element the plugin announced via its rendered event */ + element: HTMLElement; + /** Button the plugin announced via its rendered event */ + button: HTMLButtonElement; + /** Fake editor API */ + api: EditorAPI; + /** The mock backing `api.blocks.getIdByIndex` */ + getIdByIndex: jest.Mock<(index: number) => string | undefined>; +} { + const eventBus = new EventBus(); + const holder = document.createElement('div'); + + const getIdByIndex = jest.fn((index: number) => blocks[index]?.id); + + const api = { + blocks: { + getIdByIndex, + getIndexById: jest.fn((id: string) => blocks.findIndex(block => block.id === id)), + /** + * Read fresh each time, so a test can mutate the block list between opens + */ + getToolByIndex: jest.fn((index: number) => blocks[index]?.name), + }, + plugins: {}, + } as unknown as EditorAPI; + + let element: HTMLElement | undefined; + let button: HTMLButtonElement | undefined; + + /** + * Captures the element the plugin announces, which is what the toolbar would mount + * @param event - the plugin's rendered event + */ + function captureElement(event: CustomEvent): void { + const detail = event.detail as BlockSettingsRenderedUIEventPayload; + + element = detail.blockSettings; + button = detail.button as HTMLButtonElement; + } + + eventBus.addEventListener('ui:block-settings:rendered', captureElement); + + const plugin = new BlockSettingsUI({ + api, + eventBus, + /** + * The plugin reads a single field off the config, so the rest of the validated + * shape is not worth standing up here + */ + config: { holder } as unknown as CoreConfigValidated, + }); + + if (element === undefined || button === undefined) { + throw new Error('BlockSettingsUI did not announce its element and button'); + } + + holder.appendChild(button); + holder.appendChild(element); + + /** + * Attached for real: jsdom only moves focus to elements that are in the document, and + * the popover's focus handling is part of what these tests assert + */ + document.body.appendChild(holder); + + return { + plugin, + eventBus, + element, + button, + api, + getIdByIndex, + }; +} + +/** + * Titles of the menu items currently rendered, in document order + * @param element - the plugin's root element + */ +function itemTitles(element: HTMLElement): string[] { + return [...element.querySelectorAll('[role="menuitem"]')].map(item => item.textContent?.trim() ?? ''); +} + +/** + * Number of separators currently rendered + * @param element - the plugin's root element + */ +function separatorCount(element: HTMLElement): number { + return element.querySelectorAll('.ce-popover-item-separator').length; +} + +/** + * Clicks a button the way a pointer does -- `HTMLElement.click()` dispatches only the click, + * and the menu button reads its state on `mousedown`, which a real click always sends first + * @param button - the menu button under test + */ +function clickButton(button: HTMLButtonElement): void { + button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + button.click(); +} + +/** + * Activates the button the way Enter and Space do: a `keydown`, then a `click`, with no + * pointer events anywhere in between + * @param button - control to activate + */ +function activateFromKeyboard(button: HTMLButtonElement): void { + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', + bubbles: true })); + button.click(); +} + +/** + * Activates the button the way Enter does while the open menu's own keyboard handling owns the + * key: the default is prevented before the event reaches the button, so no click ever follows + * @param button - control to activate + */ +function activateFromKeyboardWithoutClick(button: HTMLButtonElement): void { + /** + * Claims the key the way ui-kit's flipper does, from a capture listener above the button + * @param event - the keydown on its way down to the button + */ + const claimTheKey = (event: Event): void => { + event.preventDefault(); + }; + + document.addEventListener('keydown', claimTheKey, { capture: true }); + + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', + bubbles: true, + cancelable: true })); + + document.removeEventListener('keydown', claimTheKey, { capture: true }); +} + +/** + * Asks the plugin to open settings for a block, then lets the providers settle + * @param eventBus - bus to dispatch on + * @param index - block position to open settings for + */ +async function open(eventBus: EventBus, index: number): Promise { + eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index })); + + await flush(); +} + +describe('BlockSettingsUI', () => { + let instance: ReturnType; + + beforeEach(() => { + document.body.innerHTML = ''; + instance = setup(); + }); + + describe('plugin contract', () => { + it('should be named block-settings', () => { + expect(BlockSettingsUI.name).toBe('block-settings'); + }); + + it('should expose register and close as its public API', () => { + expect(typeof instance.plugin.publicApi.register).toBe('function'); + expect(typeof instance.plugin.publicApi.close).toBe('function'); + }); + }); + + describe('opening', () => { + it('should resolve the target block id from the reported index', async () => { + const provider = jest.fn(() => undefined); + + instance.plugin.publicApi.register(provider); + + await open(instance.eventBus, 1); + + expect(instance.getIdByIndex).toHaveBeenCalledWith(1); + expect(provider).toHaveBeenCalledWith(expect.objectContaining({ blockId: 'block-1' })); + }); + + it('should hand providers the block index and tool alongside its id', async () => { + const provider = jest.fn(() => undefined); + + instance.plugin.publicApi.register(provider); + + await open(instance.eventBus, 2); + + expect(provider).toHaveBeenCalledWith({ + blockId: 'block-2', + blockIndex: 2, + tool: 'image', + }); + }); + + it('should call providers again on every open, so items reflect current state', async () => { + let isActive = false; + const provider = jest.fn(() => ({ + title: 'Anchor', + isActive, + onActivate: () => {}, + })); + + instance.plugin.publicApi.register(provider); + + await open(instance.eventBus, 0); + expect(provider).toHaveBeenCalledTimes(1); + + instance.plugin.publicApi.close(); + isActive = true; + + await open(instance.eventBus, 0); + expect(provider).toHaveBeenCalledTimes(2); + expect(provider.mock.results[1].value).toMatchObject({ isActive: true }); + }); + + it('should render the items a provider returns', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['Anchor']); + }); + + it('should invoke the handler of an activated item', async () => { + const onActivate = jest.fn(); + + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate, + })); + + await open(instance.eventBus, 0); + instance.element.querySelector('[role="menuitem"]')?.click(); + + expect(onActivate).toHaveBeenCalled(); + }); + + it('should accept a provider that returns a promise', async () => { + instance.plugin.publicApi.register(() => Promise.resolve({ + title: 'Async', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['Async']); + }); + }); + + describe('ordering', () => { + it('should order contributions by order then registration, separating providers', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Last', + onActivate: () => {}, + }), { order: 1000 }); + instance.plugin.publicApi.register(() => ({ + title: 'First', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['First', 'Last']); + expect(separatorCount(instance.element)).toBe(1); + }); + + it('should break ties by registration order', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Registered first', + onActivate: () => {}, + })); + instance.plugin.publicApi.register(() => ({ + title: 'Registered second', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['Registered first', 'Registered second']); + }); + + it('should not emit a separator for a provider that contributes nothing', async () => { + instance.plugin.publicApi.register(ctx => (ctx.tool === 'image' + ? undefined + : { + title: 'Only for text', + onActivate: () => {}, + })); + instance.plugin.publicApi.register(() => ({ + title: 'Always', + onActivate: () => {}, + })); + + await open(instance.eventBus, 2); + + expect(itemTitles(instance.element)).toEqual(['Always']); + expect(separatorCount(instance.element)).toBe(0); + }); + + it('should treat an empty array as no contribution', async () => { + instance.plugin.publicApi.register(() => []); + instance.plugin.publicApi.register(() => ({ + title: 'Always', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['Always']); + expect(separatorCount(instance.element)).toBe(0); + }); + }); + + describe('unregistering', () => { + it('should stop invoking a provider once it is unregistered', async () => { + const provider = jest.fn(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + const unregister = instance.plugin.publicApi.register(provider); + + unregister(); + + await open(instance.eventBus, 0); + + expect(provider).not.toHaveBeenCalled(); + expect(itemTitles(instance.element)).toEqual([]); + }); + }); + + describe('events', () => { + it('should dispatch opened once the menu is populated', async () => { + const onOpened = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:opened', onOpened); + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + await open(instance.eventBus, 1); + + expect(onOpened).toHaveBeenCalledTimes(1); + }); + + it('should dispatch closed when closed through the public API', async () => { + const onClosed = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:closed', onClosed); + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + instance.plugin.publicApi.close(); + + expect(onClosed).toHaveBeenCalledTimes(1); + }); + }); + + describe('nothing to show', () => { + it('should not open when every provider contributes nothing', async () => { + const onOpened = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:opened', onOpened); + instance.plugin.publicApi.register(() => undefined); + + await open(instance.eventBus, 0); + + expect(onOpened).not.toHaveBeenCalled(); + expect(itemTitles(instance.element)).toEqual([]); + }); + + it('should not open when no provider is registered', async () => { + const onOpened = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:opened', onOpened); + + await open(instance.eventBus, 0); + + expect(onOpened).not.toHaveBeenCalled(); + }); + + it('should do nothing when the index resolves to no block', async () => { + const provider = jest.fn(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + const onOpened = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:opened', onOpened); + instance.plugin.publicApi.register(provider); + + await open(instance.eventBus, MISSING_BLOCK_INDEX); + + expect(provider).not.toHaveBeenCalled(); + expect(onOpened).not.toHaveBeenCalled(); + expect(itemTitles(instance.element)).toEqual([]); + }); + }); + + describe('a failing provider', () => { + let reportedErrors: jest.SpiedFunction; + + beforeEach(() => { + reportedErrors = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + reportedErrors.mockRestore(); + }); + + it('should skip a provider that throws and keep the rest of the menu', async () => { + instance.plugin.publicApi.register(() => { + throw new Error('third-party provider blew up'); + }); + instance.plugin.publicApi.register(() => ({ + title: 'Healthy', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['Healthy']); + }); + + /** + * `isActive` is called not by this plugin but by ui-kit, as it renders the item -- long after the provider's own + * error handling has returned. Left there, a throwing callback aborts the popover and no menu opens at all + */ + it('should keep the rest of the menu when an isActive callback throws', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Faulty', + isActive: () => { + throw new Error('isActive blew up'); + }, + onActivate: () => {}, + })); + instance.plugin.publicApi.register(() => ({ + title: 'Healthy', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['Healthy']); + expect(instance.button.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should report an isActive failure as the provider failing', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Faulty', + isActive: () => { + throw new Error('isActive blew up'); + }, + onActivate: () => {}, + })); + instance.plugin.publicApi.register(() => ({ + title: 'Healthy', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(reportedErrors).toHaveBeenCalledWith( + expect.stringContaining('provider failed'), + expect.any(Error) + ); + }); + + it('should keep the rest of the menu when a nested isActive callback throws', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Faulty parent', + children: { + items: [{ + title: 'Faulty child', + isActive: () => { + throw new Error('isActive blew up'); + }, + onActivate: () => {}, + }], + }, + })); + instance.plugin.publicApi.register(() => ({ + title: 'Healthy', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['Healthy']); + }); + + it('should still honour an isActive callback that works', async () => { + instance.plugin.publicApi.register(() => [ + { title: 'Active', + isActive: () => true, + onActivate: () => {} }, + { title: 'Inactive', + isActive: () => false, + onActivate: () => {} }, + ]); + + await open(instance.eventBus, 0); + + const active = [...instance.element.querySelectorAll('[role="menuitem"]')] + .filter(item => item.classList.toString().includes('active')) + .map(item => item.textContent?.trim()); + + expect(active).toEqual(['Active']); + }); + + it('should skip a provider whose promise rejects', async () => { + instance.plugin.publicApi.register(() => Promise.reject(new Error('async blow-up'))); + instance.plugin.publicApi.register(() => ({ + title: 'Healthy', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(itemTitles(instance.element)).toEqual(['Healthy']); + }); + + it('should report the failure rather than swallowing it', async () => { + const failure = new Error('third-party provider blew up'); + + instance.plugin.publicApi.register(() => { + throw failure; + }); + + await open(instance.eventBus, 0); + + expect(reportedErrors).toHaveBeenCalledWith(expect.stringContaining('provider failed'), failure); + }); + + it('should skip a provider that never settles rather than never opening', async () => { + jest.useFakeTimers(); + + try { + instance.plugin.publicApi.register(() => new Promise(() => {})); + instance.plugin.publicApi.register(() => ({ + title: 'Healthy', + onActivate: () => {}, + })); + + instance.eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: 0 })); + + await jest.advanceTimersByTimeAsync(PAST_PROVIDER_TIMEOUT_MS); + + /** + * `Promise.all` has no floor of its own, so before the timeout this menu did not open + * at all -- and no later attempt could, because each one waited on the same provider + */ + expect(itemTitles(instance.element)).toEqual(['Healthy']); + expect(reportedErrors).toHaveBeenCalledWith( + expect.stringContaining('provider failed'), + expect.objectContaining({ message: expect.stringContaining('did not return') }) + ); + } finally { + jest.useRealTimers(); + } + }); + + it('should not emit a separator for the provider it skipped', async () => { + instance.plugin.publicApi.register(() => { + throw new Error('blew up'); + }); + instance.plugin.publicApi.register(() => ({ + title: 'Healthy', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + expect(separatorCount(instance.element)).toBe(0); + }); + }); + + describe('superseded and empty requests', () => { + it('should close an open menu when the next request resolves to no block', async () => { + const onClosed = jest.fn(); + + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + expect(itemTitles(instance.element)).toEqual(['Anchor']); + + instance.eventBus.addEventListener('ui:block-settings:closed', onClosed); + + await open(instance.eventBus, MISSING_BLOCK_INDEX); + + expect(onClosed).toHaveBeenCalledTimes(1); + }); + + it('should close an open menu when every provider opts out of the next block', async () => { + const onClosed = jest.fn(); + + instance.plugin.publicApi.register(ctx => (ctx.tool === 'image' + ? undefined + : { + title: 'Only text', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + expect(itemTitles(instance.element)).toEqual(['Only text']); + + instance.eventBus.addEventListener('ui:block-settings:closed', onClosed); + + await open(instance.eventBus, 2); + + // Left open, it would be showing block 0's settings while claiming to be block 2's. + expect(onClosed).toHaveBeenCalledTimes(1); + }); + + it('should abandon a build in flight when the menu is closed', async () => { + const onOpened = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:opened', onOpened); + + instance.plugin.publicApi.register(async () => { + await new Promise(resolve => setTimeout(resolve, SLOW_PROVIDER_MS)); + + return { + title: 'Anchor', + onActivate: () => {}, + }; + }); + + instance.eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: 0 })); + + /** + * Closing lands inside the build window, where `#isOpen` is still false because the + * provider has not returned. A close that only looked at `#isOpen` did nothing here, and + * the menu then opened a moment after the caller had asked for it to be gone + */ + instance.plugin.publicApi.close(); + + await new Promise(resolve => setTimeout(resolve, BOTH_REQUESTS_SETTLED_MS)); + + expect(onOpened).not.toHaveBeenCalled(); + expect(itemTitles(instance.element)).toEqual([]); + }); + + it('should abandon a build in flight when the open menu is dismissed', async () => { + const onOpened = jest.fn(); + + instance.plugin.publicApi.register(async (ctx) => { + if (ctx.blockIndex === 1) { + await new Promise(resolve => setTimeout(resolve, SLOW_PROVIDER_MS)); + } + + return { + title: `For ${ctx.blockId}`, + onActivate: () => {}, + }; + }); + + await open(instance.eventBus, 0); + expect(itemTitles(instance.element)).toEqual(['For block-0']); + + instance.eventBus.addEventListener('ui:block-settings:opened', onOpened); + + // A replacement menu is requested, and its provider has not returned yet. + instance.eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: 1 })); + + /** + * Escape and a click outside reach `#handleClosed` straight from the popover, without + * passing through `#close()`. Only that path invalidated a pending build, so a dismissed + * menu came back as soon as the replacement's provider returned + */ + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', + bubbles: true })); + + await new Promise(resolve => setTimeout(resolve, BOTH_REQUESTS_SETTLED_MS)); + + expect(onOpened).not.toHaveBeenCalled(); + }); + + it('should abandon a build in flight when the selection moves to another block', async () => { + const onOpened = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:opened', onOpened); + + instance.plugin.publicApi.register(async (ctx) => { + await new Promise(resolve => setTimeout(resolve, SLOW_PROVIDER_MS)); + + return { + title: `For ${ctx.blockId}`, + onActivate: () => {}, + }; + }); + + instance.eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: 0 })); + + /** + * `#isOpen` is still false while the providers run, so selection is still being followed. + * The build for block 0 would otherwise finish and put block 0's actions beside block 1 + */ + instance.eventBus.dispatchEvent(new BlockSelectedUIEvent({ + block: document.createElement('div'), + index: 1, + })); + + await new Promise(resolve => setTimeout(resolve, BOTH_REQUESTS_SETTLED_MS)); + + expect(onOpened).not.toHaveBeenCalled(); + expect(itemTitles(instance.element)).toEqual([]); + }); + + it('should let the newest request win when two overlap', async () => { + const onOpened = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:opened', onOpened); + + /** + * Block 0's provider resolves a tick later than block 1's, so without a guard the + * stale build would land last and render the wrong block's menu + */ + instance.plugin.publicApi.register(async (ctx) => { + if (ctx.blockIndex === 0) { + await new Promise(resolve => setTimeout(resolve, SLOW_PROVIDER_MS)); + } + + return { + title: `For ${ctx.blockId}`, + onActivate: () => {}, + }; + }); + + instance.eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: 0 })); + instance.eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: 1 })); + + await new Promise(resolve => setTimeout(resolve, BOTH_REQUESTS_SETTLED_MS)); + + expect(itemTitles(instance.element)).toEqual(['For block-1']); + expect(onOpened).toHaveBeenCalledTimes(1); + }); + }); + + describe('its button', () => { + /** + * Reports a block as hovered, which is what the button opens settings for + * @param index - position of the block + */ + function selectBlock(index: number): void { + instance.eventBus.dispatchEvent(new BlockSelectedUIEvent({ + block: document.createElement('div'), + index, + })); + } + + /** + * Announces the toolbox as open, which is when the toolbar stops following the selection + */ + function openToolbox(): void { + instance.eventBus.dispatchEvent(new ToolboxOpenedUIEvent({})); + } + + /** + * Announces the toolbox as closed, releasing the toolbar + */ + function closeToolbox(): void { + instance.eventBus.dispatchEvent(new ToolboxClosedUIEvent({})); + } + + it('should announce a button alongside the menu', () => { + expect(instance.button.tagName).toBe('BUTTON'); + }); + + it('should not submit a form it happens to sit inside', () => { + /** + * A button defaults to `submit`, and an editor mounted inside a form would then post it + * on every click of this control + */ + expect(instance.button.type).toBe('button'); + }); + + it('should close the menu when activated again from the keyboard', async () => { + const onClosed = jest.fn(); + const provider = jest.fn(() => ({ title: 'Anchor', + onActivate: () => {} })); + + instance.plugin.publicApi.register(provider); + instance.eventBus.addEventListener('ui:block-settings:closed', onClosed); + + selectBlock(0); + + clickButton(instance.button); + await flush(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('true'); + expect(provider).toHaveBeenCalledTimes(1); + + /** + * Enter and Space send a `click` with no `mousedown`, so the snapshot taken on pointer + * down says "closed" however long the menu has been open. Read only from that, the + * button built the menu a second time instead of dismissing it -- the toggle worked for + * a mouse and not for a keyboard + */ + activateFromKeyboard(instance.button); + await flush(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('false'); + expect(onClosed).toHaveBeenCalledTimes(1); + expect(provider).toHaveBeenCalledTimes(1); + }); + + /** + * The menu's own keyboard handling claims Enter and prevents the click the activation would + * otherwise produce, so the keydown is the only thing the button gets to act on + */ + it('should close the menu from the keyboard when no click follows', async () => { + instance.plugin.publicApi.register(() => ({ title: 'Anchor', + onActivate: () => {} })); + + selectBlock(0); + clickButton(instance.button); + await flush(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('true'); + + activateFromKeyboardWithoutClick(instance.button); + await flush(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not re-open the menu when a prevented key arrives with it closed', async () => { + const provider = jest.fn(() => ({ title: 'Anchor', + onActivate: () => {} })); + + instance.plugin.publicApi.register(provider); + + selectBlock(0); + activateFromKeyboardWithoutClick(instance.button); + await flush(); + + expect(provider).not.toHaveBeenCalled(); + expect(instance.button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should open from the keyboard when the menu is closed', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + selectBlock(0); + + activateFromKeyboard(instance.button); + await flush(); + + expect(itemTitles(instance.element)).toEqual(['Anchor']); + expect(instance.button.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should carry an accessible name and advertise the menu it controls', () => { + expect(instance.button.getAttribute('aria-label')).toBe('Block settings'); + expect(instance.button.getAttribute('aria-haspopup')).toBe('menu'); + expect(instance.button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should open the menu for the block it is following', async () => { + instance.plugin.publicApi.register(ctx => ({ + title: `For ${ctx.blockId}`, + onActivate: () => {}, + })); + + selectBlock(1); + clickButton(instance.button); + await flush(); + + expect(itemTitles(instance.element)).toEqual(['For block-1']); + }); + + it('should follow the menu state with aria-expanded', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + selectBlock(0); + clickButton(instance.button); + await flush(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('true'); + + instance.plugin.publicApi.close(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should hold focus before the menu is asked to open', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + selectBlock(0); + clickButton(instance.button); + await flush(); + + // Safari leaves a clicked button unfocused, and the popover would then have nowhere + // to return focus to when it closes. + expect(document.activeElement).toBe(instance.button); + }); + + it('should close the menu it opened when activated again', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + selectBlock(0); + clickButton(instance.button); + await flush(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('true'); + + // A menu button dismisses the menu it opened; without this the second click rebuilt it + // in place and left no way to close it from the control that advertises it. + clickButton(instance.button); + await flush(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not rebuild the menu when it is being closed', async () => { + const provider = jest.fn(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + instance.plugin.publicApi.register(provider); + + selectBlock(0); + clickButton(instance.button); + await flush(); + + expect(provider).toHaveBeenCalledTimes(1); + + clickButton(instance.button); + await flush(); + + expect(provider).toHaveBeenCalledTimes(1); + }); + + it('should open again after it was toggled closed', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + selectBlock(0); + clickButton(instance.button); + await flush(); + clickButton(instance.button); + await flush(); + clickButton(instance.button); + await flush(); + + expect(instance.button.getAttribute('aria-expanded')).toBe('true'); + expect(itemTitles(instance.element)).toEqual(['Anchor']); + }); + + it('should not re-target while its menu is open', async () => { + instance.plugin.publicApi.register(ctx => ({ + title: `For ${ctx.blockId}`, + onActivate: () => {}, + })); + + selectBlock(0); + clickButton(instance.button); + await flush(); + + // The menu belongs to block 0; hovering elsewhere must not move it underneath. + selectBlock(2); + instance.plugin.publicApi.close(); + clickButton(instance.button); + await flush(); + + expect(itemTitles(instance.element)).toEqual(['For block-0']); + }); + + /** + * The toolbar holds its position while the toolbox is open, so the button stays beside one block: a target that + * moved out from under it would open one block's actions next to another's, `Delete` among them + */ + it('should not re-target while the toolbox is open', async () => { + instance.plugin.publicApi.register(ctx => ({ + title: `For ${ctx.blockId}`, + onActivate: () => {}, + })); + + selectBlock(0); + openToolbox(); + selectBlock(2); + closeToolbox(); + + clickButton(instance.button); + await flush(); + + expect(itemTitles(instance.element)).toEqual(['For block-0']); + }); + + it('should follow the selection again once the toolbox closes', async () => { + instance.plugin.publicApi.register(ctx => ({ + title: `For ${ctx.blockId}`, + onActivate: () => {}, + })); + + selectBlock(0); + openToolbox(); + closeToolbox(); + selectBlock(2); + + clickButton(instance.button); + await flush(); + + expect(itemTitles(instance.element)).toEqual(['For block-2']); + }); + }); + + describe('destroy', () => { + it('should stop answering open requests', async () => { + const destroyed = setup(); + const provider = jest.fn(() => ({ title: 'Anchor', + onActivate: () => {} })); + + destroyed.plugin.publicApi.register(provider); + + destroyed.plugin.destroy(); + + await open(destroyed.eventBus, 0); + + // The bus outlives the plugin, so a listener left on it would keep building menus into + // a holder that is no longer on the page. + expect(provider).not.toHaveBeenCalled(); + }); + + it('should abandon a build in flight', async () => { + const destroyed = setup(); + const onOpened = jest.fn(); + + destroyed.eventBus.addEventListener('ui:block-settings:opened', onOpened); + + destroyed.plugin.publicApi.register(async () => { + await new Promise(resolve => setTimeout(resolve, SLOW_PROVIDER_MS)); + + return { + title: 'Anchor', + onActivate: () => {}, + }; + }); + + destroyed.eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: 0 })); + + // Dropping the listeners stops the next request, not the one already waiting. + destroyed.plugin.destroy(); + + await new Promise(resolve => setTimeout(resolve, BOTH_REQUESTS_SETTLED_MS)); + + expect(onOpened).not.toHaveBeenCalled(); + expect(itemTitles(destroyed.element)).toEqual([]); + }); + + it('should stop following block selection', () => { + const destroyed = setup(); + + destroyed.plugin.destroy(); + + expect(() => destroyed.eventBus.dispatchEvent(new BlockSelectedUIEvent({ + block: document.createElement('div'), + index: 1, + }))).not.toThrow(); + }); + + it('should take its button and menu off the page', () => { + const destroyed = setup(); + + destroyed.plugin.destroy(); + + expect(destroyed.button.isConnected).toBe(false); + expect(destroyed.element.isConnected).toBe(false); + }); + }); + + describe('accessibility', () => { + it('should name the menu from the message catalogue', async () => { + instance.plugin.publicApi.register(() => ({ + title: 'Anchor', + onActivate: () => {}, + })); + + await open(instance.eventBus, 0); + + const menu = instance.element.querySelector('[role="menu"]'); + + expect(menu?.getAttribute('aria-label')).toBe('Block settings'); + }); + + it('should leave no stale items in the accessibility tree when rebuilt smaller', async () => { + instance.plugin.publicApi.register(ctx => (ctx.tool === 'image' + ? { + title: 'Only one', + onActivate: () => {}, + } + : [ + { + title: 'One', + onActivate: () => {}, + }, + { + title: 'Two', + onActivate: () => {}, + }, + ])); + + await open(instance.eventBus, 0); + expect(itemTitles(instance.element)).toEqual(['One', 'Two']); + + instance.plugin.publicApi.close(); + await open(instance.eventBus, 2); + + expect(itemTitles(instance.element)).toEqual(['Only one']); + }); + }); +}); diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts new file mode 100644 index 00000000..05871d51 --- /dev/null +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -0,0 +1,700 @@ +import { make } from '@editorjs/dom'; +import { IconMenuSmall } from '@codexteam/icons'; +import type { + BlockId, + CoreConfigValidated, + EditorAPI, + EditorjsPlugin, + EditorjsPluginParams, + EventBus +} from '@editorjs/sdk'; +import { UiComponentType } from '@editorjs/sdk'; +import type { PopoverItemChildren, PopoverItemParams } from '@editorjs/ui-kit'; +import { PopoverDesktop, PopoverEvent, PopoverItemType } from '@editorjs/ui-kit'; +import { messages } from '../messages.js'; +import ControlStyle from '../controls.module.pcss'; +import type { BlockSelectedUIEvent } from '../Blocks/events/index.js'; +import { + BlockSettingsClosedUIEvent, + BlockSettingsOpenedUIEvent, + BlockSettingsRenderedUIEvent +} from './events/index.js'; +import type { BlockSettingsOpenUIEvent } from './events/index.js'; + +/** + * Items a provider contributes to the block settings menu. + * + * A single item or a list of them, in the vocabulary `@editorjs/ui-kit` popovers already use. + * Named apart from `@editorjs/sdk`'s `MenuConfig`, which is a different, narrower shape for + * inline tools' toolbar config: + * an item's behaviour comes entirely from its own params (`onActivate`, `isActive`, + * `isDisabled`, `closeOnActivate`, `children`, or a `confirmation` whose handler runs on the + * second activation), so the menu itself interprets nothing. + */ +export type BlockSettingsMenuConfig = PopoverItemParams | PopoverItemParams[]; + +/** + * The block a provider is being asked about + */ +export interface BlockSettingsContext { + /** + * Id of the block the menu is being built for. + * + * Identifies the block for as long as it exists, which is why an item's handler should + * resolve the block's current position from it rather than reusing {@link blockIndex} + */ + blockId: BlockId; + + /** + * Position the block held at the moment the menu was built. + * + * Only valid then: the document may change while the menu is open, so a handler acting on + * this number can hit a different block than the user opened the menu on + */ + blockIndex: number; + + /** + * Name of the tool rendering the block, for providers that only apply to some tools + */ + tool: string; +} + +/** + * A function asked for its menu items each time block settings open. + * + * Returning `undefined` or an empty list opts out for that block, contributing neither items + * nor a separator. + */ +export type BlockSettingsProvider = ( + context: BlockSettingsContext +) => BlockSettingsMenuConfig | undefined | Promise; + +/** + * Options a provider is registered with + */ +export interface BlockSettingsRegisterOptions { + /** + * Ascending sort key for this provider's contribution, defaulting to `0` with ties broken by + * registration order. The built-in settings sit last, at `1000` + */ + order?: number; +} + +/** + * Public API the Block settings plugin exposes as `api.plugins['block-settings']` + */ +export interface BlockSettingsAPI { + /** + * Registers a settings provider + * @param provider - called with the target block each time the menu opens + * @param options - registration options, `order` among them + * @returns a function that unregisters the provider + */ + register(provider: BlockSettingsProvider, options?: BlockSettingsRegisterOptions): () => void; + + /** + * Closes the menu if it is open + */ + close(): void; +} + +declare module '@editorjs/sdk' { + /* eslint-disable jsdoc/require-jsdoc -- interface members are documented on the types they alias */ + interface EditorjsPluginApiMap { + /** + * Block settings plugin's public API + */ + // eslint-disable-next-line @typescript-eslint/naming-convention -- the key is the plugin's `name`, which is kebab-case + 'block-settings': BlockSettingsAPI; + } + /* eslint-enable jsdoc/require-jsdoc */ +} + +/** + * How long one provider may take before the menu is built without it. + * + * Long enough that a provider reading something off the network still makes it, short enough + * that the menu is not simply unresponsive while one waits + */ +const PROVIDER_TIMEOUT_MS = 2000; + +/** + * A registered provider together with what it was registered with + */ +interface Registration { + /** The provider itself */ + provider: BlockSettingsProvider; + /** Sort key */ + order: number; + /** Registration counter, used to keep equal orders in registration sequence */ + sequence: number; +} + +/** + * Replaces every `isActive` callback in an item tree with the boolean it returns: ui-kit calls them as it renders, + * long after the provider's own `try` has returned, and a throw there aborts the popover and every provider's items + * @param item - item as the provider returned it + */ +function withResolvedActiveState(item: PopoverItemParams): PopoverItemParams { + const resolved: Record = { ...item }; + + if (typeof resolved.isActive === 'function') { + resolved.isActive = (resolved.isActive as () => boolean)(); + } + + const children = resolved.children as PopoverItemChildren | undefined; + + /** + * Nested items render with their parent, so their callbacks are called just as early + */ + if (children?.items !== undefined) { + resolved.children = { + ...children, + items: children.items.map(withResolvedActiveState), + }; + } + + const confirmation = resolved.confirmation as PopoverItemParams | undefined; + + /** + * The confirmation state replaces the item's own params on activation and renders from them the same way + */ + if (confirmation !== undefined) { + resolved.confirmation = withResolvedActiveState(confirmation); + } + + return resolved as PopoverItemParams; +} + +/** + * UI plugin that renders the per-block settings menu and the button that opens it, announcing + * both once for a host to mount. It owns no items of its own -- everything in the menu comes + * from registered providers, the built-in Move/Delete entries included. + */ +export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { + /** + * Plugin type + */ + public static readonly type = UiComponentType.BlockSettings; + + /** + * Plugin name -- keys `api.plugins['block-settings']` + */ + public static readonly name = 'block-settings'; + + /** + * API registering providers and closing the menu + */ + public readonly publicApi: BlockSettingsAPI; + + /** + * EditorAPI instance, used to resolve the target block + */ + #api: EditorAPI; + + /** + * EventBus instance to exchange events between components + */ + #eventBus: EventBus; + + /** + * Editor configuration + */ + #editorConfig: CoreConfigValidated; + + /** + * Stable element handed to the host. The popover inside it is replaced per open, + * so this is what stays mounted + */ + #holder: HTMLElement; + + /** + * The control that opens the menu. Owned here rather than by the toolbar: the button and + * the menu are one widget, and every piece of its menu-button contract -- the accessible + * name, `aria-haspopup`, the `aria-expanded` that has to track the menu's real state -- + * depends on state only this plugin has + */ + #button: HTMLButtonElement; + + /** + * Position of the block the menu will be built for, as last reported by block selection. + * + * Frozen while the menu is open: the menu belongs to one block, and re-targeting under it + * would leave the button and the open menu pointing at different blocks + */ + #selectedBlockIndex = -1; + + /** + * Whether the toolbox popover is open. The toolbar holds its position while it is, since that popover renders inside + * the toolbar -- so the button stays beside one block, and the block the menu acts on has to stay there with it + */ + #isToolboxOpen = false; + + /** + * Popover rendering the current menu, absent until the menu is first built + */ + #popover: PopoverDesktop | undefined; + + /** + * Whether the menu is open, as this plugin understands it. + * + * Tracked here rather than read back off the popover, whose own shown state is its business + * and does not reliably survive the click that opened the menu. This is the state the + * button's `aria-expanded` and the `opened`/`closed` events promise + */ + #isOpen = false; + + /** + * Whether the menu was open when the activation that is now in progress began. + * + * The popover closes itself from the click, and it gets there before this button's own + * handler -- so by `click` the menu is already gone, and a toggle reading the state then + * would see every second activation as a fresh open. The last moment the answer is still + * true is `mousedown` for a pointer, and the `keydown` of Enter or Space for a keyboard, + * which is why both are recorded here rather than only the first + */ + #wasOpenOnActivationStart = false; + + /** + * Registered providers + */ + readonly #registrations = new Set(); + + /** + * Incremented per registration so providers sharing an `order` keep their registration sequence + */ + #sequence = 0; + + /** + * Incremented on every open request. Building a menu is asynchronous, so a request that + * finishes after a newer one started has been superseded and must not render + */ + #openToken = 0; + + /** + * Index the request currently being built is for, or `null` when nothing is in flight. + * + * Providers may be asynchronous, so the block a menu is being built for can stop being the + * selected one before the menu exists + */ + #buildingFor: number | null = null; + + /** + * BlockSettingsUI class constructor + * @param params - Plugin parameters + */ + constructor({ api, eventBus, config }: EditorjsPluginParams) { + this.#api = api; + this.#eventBus = eventBus; + this.#editorConfig = config; + + this.publicApi = { + register: (provider, options) => this.#register(provider, options), + close: () => this.#close(), + }; + + this.#holder = make('div'); + this.#button = make('button', ControlStyle['toolbar-button'], { + innerHTML: IconMenuSmall, + /** + * A button defaults to `submit`, and the editor's holder may sit inside a form, where + * opening the settings menu would submit it + */ + type: 'button', + }) as HTMLButtonElement; + + this.#renderButton(); + + this.#eventBus.dispatchEvent(new BlockSettingsRenderedUIEvent({ + button: this.#button, + blockSettings: this.#holder, + })); + + this.#eventBus.addEventListener('ui:blocks:block-selected', this.#handleBlockSelected); + this.#eventBus.addEventListener('ui:block-settings:open', this.#handleOpenRequest); + this.#eventBus.addEventListener('ui:toolbox:opened', this.#handleToolboxOpened); + this.#eventBus.addEventListener('ui:toolbox:closed', this.#handleToolboxClosed); + } + + /** + * Cleanup when plugin is destroyed + */ + public destroy(): void { + /** + * The bus outlives the plugin, so a listener left on it keeps this instance alive and + * answering: an open request after destroy would build a menu into a detached holder + */ + this.#eventBus.removeEventListener('ui:blocks:block-selected', this.#handleBlockSelected); + this.#eventBus.removeEventListener('ui:block-settings:open', this.#handleOpenRequest); + this.#eventBus.removeEventListener('ui:toolbox:opened', this.#handleToolboxOpened); + this.#eventBus.removeEventListener('ui:toolbox:closed', this.#handleToolboxClosed); + + /** + * Dropping the listeners stops new requests, but not one already waiting on its providers. + * That one would render into the holder removed just below + */ + this.#abandonPendingBuild(); + + this.#popover?.destroy(); + this.#popover = undefined; + this.#holder.remove(); + this.#button.remove(); + } + + /** + * Follows block selection, except while the menu or the toolbox is open: whenever the toolbar holds its position, + * re-targeting here would leave the button and this target on different blocks + * @param event - the selection event + */ + #handleBlockSelected = (event: BlockSelectedUIEvent): void => { + if (this.#isOpen || this.#isToolboxOpen) { + return; + } + + const { index } = event.detail; + + /** + * A build in flight was started for whichever block was selected at the time. Letting it + * finish after the selection has moved would open that block's actions beside this one, + * which is a menu that acts on a block the user is no longer pointing at + */ + if (this.#buildingFor !== null && this.#buildingFor !== index) { + this.#abandonPendingBuild(); + } + + this.#selectedBlockIndex = index; + }; + + /** + * Opens the menu for the block another component asked about + * @param event - the open request + */ + #handleOpenRequest = (event: BlockSettingsOpenUIEvent): void => { + void this.#open(event.detail.index); + }; + + /** + * Freezes the target for as long as the toolbox holds the toolbar in place + */ + #handleToolboxOpened = (): void => { + this.#isToolboxOpen = true; + }; + + /** + * Lets the target follow the selection again, from the next block reported + */ + #handleToolboxClosed = (): void => { + this.#isToolboxOpen = false; + }; + + /** + * Gives the button its menu-button semantics and wires it to the menu + */ + #renderButton(): void { + this.#button.setAttribute('aria-label', messages.blockSettingsButton); + this.#button.setAttribute('aria-haspopup', 'menu'); + this.#button.setAttribute('aria-expanded', 'false'); + + this.#button.addEventListener('mousedown', () => { + this.#wasOpenOnActivationStart = this.#isOpen; + }); + + /** + * Enter and Space send a `click` of their own, with no pointer events before it. Their + * `keydown` is where that activation starts, so it is the keyboard's `mousedown` + */ + this.#button.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + this.#wasOpenOnActivationStart = this.#isOpen; + + /** + * No click follows a key the open menu's own handling has claimed, so the toggle on it never runs: closing + * here is the only way left to dismiss the menu from the button that opened it + */ + if (this.#isOpen && event.defaultPrevented) { + this.#close(); + } + }); + + this.#button.addEventListener('click', () => { + /** + * The snapshot is the answer whenever the popover has already dismissed itself over this + * activation, which is the usual order. The live state covers the case where it has not + * got there yet, so the toggle does not depend on which of the two runs first + */ + const wasOpen = this.#wasOpenOnActivationStart || this.#isOpen; + + this.#wasOpenOnActivationStart = false; + + /** + * Safari leaves a clicked button unfocused, and the popover would then capture + * `document.body` as the element to restore focus to when it closes. Focusing the + * button that owns the menu is also what the WAI-ARIA menu button pattern asks for + */ + this.#button.focus(); + + /** + * A menu button dismisses the menu it opened. The popover has already closed itself by + * now, so there is usually nothing left to close -- what matters is not re-opening it, + * which is what made the button look inert on every second click + */ + if (wasOpen) { + this.#close(); + + return; + } + + void this.#open(this.#selectedBlockIndex); + }); + } + + /** + * Registers a provider and returns its unregister function + * @param provider - called with the target block each time the menu opens + * @param options - registration options + */ + #register(provider: BlockSettingsProvider, options?: BlockSettingsRegisterOptions): () => void { + const registration: Registration = { + provider, + order: options?.order ?? 0, + sequence: this.#sequence++, + }; + + this.#registrations.add(registration); + + return () => { + this.#registrations.delete(registration); + }; + } + + /** + * Closes the menu if it is open. + * + * `hide()` is asked first, so the popover tears down its own focus handling, and the + * bookkeeping is then run directly: a popover that has already dropped its shown state hides + * without emitting anything, which would leave the button advertising a menu that is gone + */ + #close(): void { + this.#abandonPendingBuild(); + + if (!this.#isOpen) { + return; + } + + this.#popover?.hide(); + + this.#handleClosed(); + } + + /** + * Records the menu as closed, once, whichever route closed it + */ + #handleClosed = (): void => { + /** + * Escape and a click outside reach this directly from the popover, without passing through + * `#close()`, so the two close paths have to abandon a build in flight alike. A menu can be + * mid-build rather than open -- `#isOpen` is only set once the providers have returned -- + * which is why this runs before the open-state guard below + */ + this.#abandonPendingBuild(); + + if (!this.#isOpen) { + return; + } + + this.#isOpen = false; + + this.#button.setAttribute('aria-expanded', 'false'); + + this.#eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); + }; + + /** + * Discards whichever menu is still being built, so that it never reaches the screen. + * + * Bumping the token is what the build checks before rendering; `#buildingFor` goes with it, + * since there is no longer a block being built for + */ + #abandonPendingBuild(): void { + this.#openToken++; + this.#buildingFor = null; + } + + /** + * Builds the menu for a block and opens it, unless there is nothing to show + * @param index - position the opening request named + */ + async #open(index: number): Promise { + const token = ++this.#openToken; + + this.#buildingFor = index; + + const blockId = this.#api.blocks.getIdByIndex(index); + + /** + * Nothing is selected, or the index is stale, so no provider is asked. Any menu still on + * screen was built for a different block, and leaving it open would present one block's + * settings as another's + */ + if (blockId === undefined) { + this.#close(); + + return; + } + + const items = await this.#collectItems({ + blockId, + blockIndex: index, + tool: this.#api.blocks.getToolByIndex(index) ?? '', + }); + + /** + * A newer request started while this one was waiting on its providers. That one owns the + * menu now, and rendering this result would replace it with a stale block's items + */ + if (token !== this.#openToken) { + return; + } + + this.#buildingFor = null; + + /** + * Every provider opted out of this block. Same reasoning as the missing block above: the + * menu does not open, and whatever was open closes rather than misrepresenting this block + */ + if (items.length === 0) { + this.#close(); + + return; + } + + this.#renderPopover(items); + + this.#popover?.show(); + + this.#isOpen = true; + this.#button.setAttribute('aria-expanded', 'true'); + + this.#eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({})); + } + + /** + * Asks every registered provider for its items and concatenates the contributions, + * separated from one another + * @param context - the block being asked about + */ + async #collectItems(context: BlockSettingsContext): Promise { + const ordered = [...this.#registrations].sort( + (a, b) => (a.order - b.order) || (a.sequence - b.sequence) + ); + + const contributions = await Promise.all( + ordered.map(async ({ provider }) => { + try { + const result = await this.#withTimeout(provider(context)); + + if (result === undefined) { + return []; + } + + const items = Array.isArray(result) ? result : [result]; + + /** + * Inside the `try` on purpose: an `isActive` that throws is this provider failing, so it costs this + * provider's items rather than the whole menu + */ + return items.map(withResolvedActiveState); + } catch (error) { + /** + * Providers are third-party code and this is the one place all of it runs, so without + * isolation a single plugin throwing takes the whole menu down for every block. It + * contributes nothing instead, and the stack identifies which one it was + */ + console.error('[BlockSettingsUI] A block settings provider failed and was skipped', error); + + return []; + } + }) + ); + + return contributions + .filter(contribution => contribution.length > 0) + .reduce((items, contribution) => { + if (items.length > 0) { + items.push({ type: PopoverItemType.Separator }); + } + + return items.concat(contribution); + }, []); + } + + /** + * Rejects if a provider has not settled in time, so that the one waiting on it is skipped + * rather than holding up the menu. + * + * Without this the `Promise.all` over providers has no floor: a provider that never settles + * means the menu never opens, and never opens again either, because every later attempt waits + * on the same provider. A rejection instead lands in the same skip-and-log path as one that + * threw, which is the behaviour a plugin author already has to expect + * @param result - what the provider returned + */ + async #withTimeout( + result: BlockSettingsMenuConfig | undefined | Promise + ): Promise { + let timer: ReturnType | undefined; + + try { + return await Promise.race([ + result, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`A block settings provider did not return within ${PROVIDER_TIMEOUT_MS}ms`)), + PROVIDER_TIMEOUT_MS + ); + }), + ]); + } finally { + clearTimeout(timer); + } + } + + /** + * Replaces the popover inside the holder with one built for the given items. Rebuilt rather + * than edited in place because `removeItemByName` is ui-kit's only removal and a separator + * carries no name, so a fresh popover is what keeps the previous block's items out of the + * accessibility tree + * @param items - items the menu should consist of + */ + #renderPopover(items: PopoverItemParams[]): void { + this.#popover?.destroy(); + + /** + * The item wrapper is left at ui-kit's default: `wrapperTag: 'button'`, carried over from + * PR #157, is what ui-kit uses for its *inline* popover, where shrink-to-fit is the right + * width. In a vertical menu a form control does not stretch to its container, so each row + * came out as wide as its own label -- a partial hover highlight and the UA button font + */ + this.#popover = new PopoverDesktop({ + scopeElement: this.#editorConfig.holder, + searchable: false, + items, + + /** + * Names the menu for assistive technologies, matching the button that opens it + */ + messages: { + label: messages.blockSettingsMenu, + }, + }); + + this.#popover.on(PopoverEvent.Closed, this.#handleClosed); + + this.#holder.innerHTML = ''; + this.#holder.appendChild(this.#popover.getElement()); + } +} + +export * from './events/index.js'; diff --git a/packages/ui/src/BlockSettings/events/BlockSettingsBaseEvent.ts b/packages/ui/src/BlockSettings/events/BlockSettingsBaseEvent.ts new file mode 100644 index 00000000..814db33f --- /dev/null +++ b/packages/ui/src/BlockSettings/events/BlockSettingsBaseEvent.ts @@ -0,0 +1,15 @@ +import { UIEventBase } from '@editorjs/sdk'; + +/** + * Base event class for BlockSettings events + */ +export class BlockSettingsBaseEvent extends UIEventBase { + /** + * Constructor function + * @param name - name of a BlockSettings event + * @param payload - generic payload + */ + constructor(name: string, payload: Payload) { + super(`block-settings:${name}`, payload); + } +} diff --git a/packages/ui/src/BlockSettings/events/BlockSettingsClosedUIEvent.ts b/packages/ui/src/BlockSettings/events/BlockSettingsClosedUIEvent.ts new file mode 100644 index 00000000..533f5672 --- /dev/null +++ b/packages/ui/src/BlockSettings/events/BlockSettingsClosedUIEvent.ts @@ -0,0 +1,20 @@ +import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; + +/** + * Payload of the BlockSettingsClosedUIEvent - empty for now + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface BlockSettingsClosedUIEventPayload {} + +/** + * Class for event that is being fired after block settings have been closed + */ +export class BlockSettingsClosedUIEvent extends BlockSettingsBaseEvent { + /** + * BlockSettingsClosedUIEvent constructor function + * @param payload - BlockSettingsClosed event payload + */ + constructor(payload: BlockSettingsClosedUIEventPayload) { + super('closed', payload); + } +} diff --git a/packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts b/packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts new file mode 100644 index 00000000..330e5ef4 --- /dev/null +++ b/packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts @@ -0,0 +1,26 @@ +import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; + +/** + * Names the block whose settings should be shown + */ +export interface BlockSettingsOpenUIEventPayload { + /** + * Position of the block to show settings for, named by index because callers follow block + * selection by position. `BlockSettingsUI` resolves it to an id once the menu opens, and the + * providers' items work from that id afterwards + */ + readonly index: number; +} + +/** + * Class for event that is being fired when block settings should be opened + */ +export class BlockSettingsOpenUIEvent extends BlockSettingsBaseEvent { + /** + * BlockSettingsOpenUIEvent constructor function + * @param payload - BlockSettingsOpen event payload + */ + constructor(payload: BlockSettingsOpenUIEventPayload) { + super('open', payload); + } +} diff --git a/packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts b/packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts new file mode 100644 index 00000000..4166e5e3 --- /dev/null +++ b/packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts @@ -0,0 +1,20 @@ +import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; + +/** + * Payload of the BlockSettingsOpenedUIEvent - empty for now + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface BlockSettingsOpenedUIEventPayload {} + +/** + * Class for event that is being fired after block settings have been opened + */ +export class BlockSettingsOpenedUIEvent extends BlockSettingsBaseEvent { + /** + * BlockSettingsOpenedUIEvent constructor function + * @param payload - BlockSettingsOpened event payload + */ + constructor(payload: BlockSettingsOpenedUIEventPayload) { + super('opened', payload); + } +} diff --git a/packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts b/packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts new file mode 100644 index 00000000..c7371465 --- /dev/null +++ b/packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts @@ -0,0 +1,34 @@ +import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; + +/** + * Payload of the BlockSettingsRenderedUIEvent + * Carries the elements the plugin owns, for a host to mount + */ +export interface BlockSettingsRenderedUIEventPayload { + /** + * The control that opens the menu. + * + * Announced separately from the menu because a host has to place it among its own + * controls -- `ToolbarUI` puts it in its actions container, where it takes part in the + * toolbar's roving tabindex and so has to be a child of that container in its own right + */ + readonly button: HTMLElement; + + /** + * The element the menu renders into + */ + readonly blockSettings: HTMLElement; +} + +/** + * Class for event that is being fired after the block settings menu is rendered + */ +export class BlockSettingsRenderedUIEvent extends BlockSettingsBaseEvent { + /** + * BlockSettingsRenderedUIEvent constructor function + * @param payload - BlockSettingsRendered event payload + */ + constructor(payload: BlockSettingsRenderedUIEventPayload) { + super('rendered', payload); + } +} diff --git a/packages/ui/src/BlockSettings/events/index.ts b/packages/ui/src/BlockSettings/events/index.ts new file mode 100644 index 00000000..02ce2d61 --- /dev/null +++ b/packages/ui/src/BlockSettings/events/index.ts @@ -0,0 +1,5 @@ +export * from './BlockSettingsBaseEvent.js'; +export * from './BlockSettingsRenderedUIEvent.js'; +export * from './BlockSettingsOpenUIEvent.js'; +export * from './BlockSettingsOpenedUIEvent.js'; +export * from './BlockSettingsClosedUIEvent.js'; diff --git a/packages/ui/src/Blocks/Blocks.selection.spec.ts b/packages/ui/src/Blocks/Blocks.selection.spec.ts new file mode 100644 index 00000000..86cf82da --- /dev/null +++ b/packages/ui/src/Blocks/Blocks.selection.spec.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { EditorAPI, EditorjsPluginParams } from '@editorjs/sdk'; +import { BlockAddedCoreEvent, EventBus } from '@editorjs/sdk'; +import { BlocksUI } from './Blocks.js'; +import type { BlocksHolderRenderedUIEvent, BlockSelectedUIEvent } from './events/index.js'; + +/** Enough blocks to tell "moved to another one" from "stayed put". */ +const THREE_BLOCKS = 3; + +/** The smallest document where a second block exists to move to. */ +const TWO_BLOCKS = 2; + +/** + * Mounts `BlocksUI` over a bus and gives it `count` blocks, each holding its own text node. + * + * The blocks are attached to the document for real: the caret is found by asking which block + * contains the selection's anchor, and a detached node is in no selection + * @param count - how many blocks the document starts with + */ +function setup(count: number): { + /** Event bus the instance is wired to */ + eventBus: EventBus; + /** Instance under test */ + plugin: BlocksUI; + /** The text nodes the blocks were given, in document order */ + texts: Text[]; + /** Indexes reported through `ui:blocks:block-selected`, in order */ + selected: number[]; +} { + const eventBus = new EventBus(); + const selected: number[] = []; + + eventBus.addEventListener('ui:blocks:block-selected', (event: BlockSelectedUIEvent) => { + selected.push(event.detail.index); + }); + + /** + * Attached as soon as it is announced, and before any block is added: a selection can only + * be placed in nodes that are in the document + */ + eventBus.addEventListener('ui:blocks:rendered', (event: BlocksHolderRenderedUIEvent) => { + document.body.appendChild(event.detail.blocksHolder); + }); + + const plugin = new BlocksUI({ + eventBus, + api: {} as EditorAPI, + } as EditorjsPluginParams); + + const texts: Text[] = []; + + for (let index = 0; index < count; index++) { + const blockElement = document.createElement('div'); + const text = document.createTextNode(`Block ${index}`); + + blockElement.appendChild(text); + texts.push(text); + + eventBus.dispatchEvent(new BlockAddedCoreEvent({ + tool: 'paragraph', + data: {}, + ui: blockElement, + index, + })); + } + + return { + eventBus, + plugin, + texts, + selected, + }; +} + +/** + * Moves the pointer into a block, the way hovering it does + * @param node - a text node inside the block being entered + */ +function hoverBlock(node: Text): void { + // BlocksUI wraps what it is given: wrapper > contents > the element handed to it > this text. + const wrapper = node.parentElement?.parentElement?.parentElement; + + wrapper?.dispatchEvent(new MouseEvent('mouseenter')); +} + +/** + * Puts the caret in a text node, the way clicking or arrowing into a block does + * @param node - text node to place the caret in + */ +function putCaretIn(node: Text): void { + const range = document.createRange(); + + range.setStart(node, 0); + range.collapse(true); + + const selection = document.getSelection(); + + selection?.removeAllRanges(); + selection?.addRange(range); + + document.dispatchEvent(new Event('selectionchange')); +} + +describe('BlocksUI', () => { + beforeEach(() => { + document.body.innerHTML = ''; + document.getSelection()?.removeAllRanges(); + }); + + describe('reporting the selected block', () => { + it('should report the block the caret moved into', () => { + const instance = setup(THREE_BLOCKS); + + putCaretIn(instance.texts[2]); + + // Hover was the only source of selection, which left every control acting on "the + // selected block" doing nothing at all for a user who never moves a pointer. + expect(instance.selected).toEqual([2]); + }); + + it('should report each block the caret moves between', () => { + const instance = setup(THREE_BLOCKS); + + putCaretIn(instance.texts[0]); + putCaretIn(instance.texts[1]); + + expect(instance.selected).toEqual([0, 1]); + }); + + it('should not report the same block twice', () => { + const instance = setup(TWO_BLOCKS); + + putCaretIn(instance.texts[1]); + // `selectionchange` fires on every keystroke; only a move to another block is news. + putCaretIn(instance.texts[1]); + + expect(instance.selected).toEqual([1]); + }); + + it('should report a block the pointer re-enters, even if it was the last one reported', () => { + const { texts, selected } = setup(TWO_BLOCKS); + + hoverBlock(texts[0]); + hoverBlock(texts[1]); + hoverBlock(texts[0]); + hoverBlock(texts[1]); + + /** + * Pointer entry is not deduplicated, because `mouseenter` only fires on actually + * crossing into a block -- there is no repetition to suppress. Suppressing it stranded + * anything that had ignored the last announcement: the toolbar and the settings menu + * both ignore selection while the menu is open, so after dismissing a menu opened on + * another block, coming back to this one told them nothing and they kept acting on it + */ + expect(selected).toEqual([0, 1, 0, 1]); + }); + + it('should still report a repeated pointer entry after the caret reported the same block', () => { + const { texts, selected } = setup(TWO_BLOCKS); + + putCaretIn(texts[1]); + hoverBlock(texts[1]); + + expect(selected).toEqual([1, 1]); + }); + + it('should report nothing for a selection outside every block', () => { + const instance = setup(TWO_BLOCKS); + const outside = document.createTextNode('elsewhere'); + + document.body.appendChild(outside); + + putCaretIn(outside); + + expect(instance.selected).toEqual([]); + }); + + it('should stop listening once destroyed', () => { + const instance = setup(TWO_BLOCKS); + + instance.plugin.destroy(); + + putCaretIn(instance.texts[1]); + + // The listener is on `document`, which outlives every editor on the page. + expect(instance.selected).toEqual([]); + }); + }); +}); diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index 2566de5e..aa1fe82c 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -37,6 +37,12 @@ export class BlocksUI implements EditorjsPlugin { */ #blocks: HTMLElement[] = []; + /** + * Position of the block last announced as selected, so the same one is not announced twice. + * `selectionchange` fires on every keystroke, and only a move to another block is news + */ + #selectedBlockIndex = -1; + /** * EventBus instance to exchange events between components */ @@ -75,6 +81,8 @@ export class BlocksUI implements EditorjsPlugin { this.#removeBlock(index); }, { signal }); + document.addEventListener('selectionchange', this.#handleSelectionChange, { signal }); + this.#eventBus.dispatchEvent(new BlocksHolderRenderedUIEvent({ blocksHolder: this.#blocksHolder, })); @@ -253,7 +261,67 @@ export class BlocksUI implements EditorjsPlugin { */ #updateSelectedBlock(event: MouseEvent): void { const block = event.target as HTMLElement; - const index = this.#blocks.indexOf(block); + + /** + * Announced without deduplication, unlike a caret move. `mouseenter` only fires when the + * pointer actually crosses into a block, so there is no repetition here to suppress -- and + * suppressing it is wrong: a listener that ignored the last announcement, as the toolbar + * and the settings menu both do while the menu is open, would otherwise never hear that + * the pointer came back + */ + this.#announceSelectedBlock(block, this.#blocks.indexOf(block)); + } + + /** + * Reports the block the caret is in, so that "the selected block" means something to a user + * who never moves a pointer. + * + * Hover was the only source of selection, which left every control acting on the selected + * block -- the toolbar's settings button among them -- doing nothing at all from the + * keyboard. Read here rather than from the editor's own caret state because that is cleared + * the moment focus leaves the editable, which is exactly what reaching for a toolbar does + */ + #handleSelectionChange = (): void => { + const selection = document.getSelection(); + const anchor = selection?.anchorNode ?? null; + + if (anchor === null) { + return; + } + + const index = this.#blocks.findIndex(block => block.contains(anchor)); + + if (index === -1) { + return; + } + + this.#selectBlock(this.#blocks[index], index); + }; + + /** + * Announces the block to work on, unless it is the one already announced + * @param block - the block's wrapper element + * @param index - its position in the document + */ + #selectBlock(block: HTMLElement, index: number): void { + /** + * `selectionchange` fires for every caret move, most of which stay inside one block, so + * announcing each one would be noise. Pointer entry is announced unconditionally instead + */ + if (index === this.#selectedBlockIndex) { + return; + } + + this.#announceSelectedBlock(block, index); + } + + /** + * Reports the selected block to everything that follows it + * @param block - element of the selected block + * @param index - position of the block in the document + */ + #announceSelectedBlock(block: HTMLElement, index: number): void { + this.#selectedBlockIndex = index; this.#eventBus.dispatchEvent(new BlockSelectedUIEvent({ block, @@ -278,6 +346,8 @@ export class BlocksUI implements EditorjsPlugin { * Detaching the holder takes the block wrappers inside it along */ this.#blocksHolder.remove(); + this.#blocks = []; + this.#selectedBlockIndex = -1; } } diff --git a/packages/ui/src/Toolbar/Toolbar.const.ts b/packages/ui/src/Toolbar/Toolbar.const.ts index 02a9b221..1c113649 100644 --- a/packages/ui/src/Toolbar/Toolbar.const.ts +++ b/packages/ui/src/Toolbar/Toolbar.const.ts @@ -5,5 +5,4 @@ const className = bem('toolbar'); export const css = { toolbar: className(), actions: className('actions'), - plusButton: className('plus-button'), }; diff --git a/packages/ui/src/Toolbar/Toolbar.module.pcss b/packages/ui/src/Toolbar/Toolbar.module.pcss index 07c97157..2e928316 100644 --- a/packages/ui/src/Toolbar/Toolbar.module.pcss +++ b/packages/ui/src/Toolbar/Toolbar.module.pcss @@ -1,34 +1,3 @@ -:root { - - --toolbar-button { - background: none; - border: none; - padding: 0; - - color: var(--base--text); - cursor: pointer; - width: 26px; - height: 26px; - border-radius: var(--radius-m); - display: inline-flex; - justify-content: center; - align-items: center; - user-select: none; - - @media (hover: hover) { - &:hover { - background-color: var(--accent--bg-primary); - } - } - - &--active { - background-color: var(--accent--bg-primary); - animation: bounceIn 0.75s 1; - animation-fill-mode: forwards; - } - } -} - .toolbar { position: absolute; left: 0; @@ -42,11 +11,7 @@ &__actions { position: absolute; right: 100%; - } - - &__plus-button { - @apply --toolbar-button; - - flex-shrink: 0; + display: flex; + align-items: center; } } diff --git a/packages/ui/src/Toolbar/Toolbar.spec.ts b/packages/ui/src/Toolbar/Toolbar.spec.ts new file mode 100644 index 00000000..77c347b1 --- /dev/null +++ b/packages/ui/src/Toolbar/Toolbar.spec.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { EditorAPI } from '@editorjs/sdk'; +import { EventBus } from '@editorjs/sdk'; +import { ToolbarUI } from './Toolbar.js'; +import type { ToolbarRenderedUIEventPayload } from './ToolbarRenderedUIEvent.js'; +import { BlockSelectedUIEvent } from '../Blocks/events/index.js'; +import { BlockSettingsClosedUIEvent, BlockSettingsOpenedUIEvent, BlockSettingsRenderedUIEvent } from '../BlockSettings/events/index.js'; +import { messages } from '../messages.js'; + +/** + * Builds a toolbar over a fake editor and mounts the element it announces + */ +function setup(): { + /** Instance under test */ + plugin: ToolbarUI; + /** Event bus the instance is wired to */ + eventBus: EventBus; + /** Element the instance announced */ + holder: HTMLElement; +} { + const eventBus = new EventBus(); + const api = { blocks: {} } as unknown as EditorAPI; + + let holder: HTMLElement | undefined; + + /** + * Captures the element the toolbar announces + * @param event - the toolbar's rendered event + */ + function captureHolder(event: CustomEvent): void { + holder = (event.detail as ToolbarRenderedUIEventPayload).toolbar; + } + + eventBus.addEventListener('ui:toolbar:rendered', captureHolder); + + const plugin = new ToolbarUI({ + api, + eventBus, + } as never); + + if (holder === undefined) { + throw new Error('ToolbarUI did not announce its element'); + } + + document.body.appendChild(holder); + + return { + plugin, + eventBus, + holder, + }; +} + +/** + * The toolbar's own action buttons + * @param holder - the toolbar element + */ +function actionButtons(holder: HTMLElement): HTMLButtonElement[] { + return [...holder.querySelectorAll('[role="toolbar"] > button')]; +} + +/** + * Announces a block settings widget the way `BlockSettingsUI` does, so the toolbar mounts it + * @param eventBus - bus to dispatch on + * @returns the button and menu element that were announced + */ +function announceBlockSettings(eventBus: EventBus): { + /** The control that opens the menu */ + button: HTMLButtonElement; + /** The element the menu renders into */ + menu: HTMLElement; +} { + const button = document.createElement('button'); + const menu = document.createElement('div'); + + button.setAttribute('aria-label', messages.blockSettingsButton); + menu.dataset.testid = 'block-settings-menu'; + + eventBus.dispatchEvent(new BlockSettingsRenderedUIEvent({ + button, + blockSettings: menu, + })); + + return { + button, + menu, + }; +} + +/** + * Reports a block as hovered, which is what the toolbar follows + * @param eventBus - bus to dispatch on + * @param index - position of the block + */ +function selectBlock(eventBus: EventBus, index: number): void { + eventBus.dispatchEvent(new BlockSelectedUIEvent({ + block: document.createElement('div'), + index, + })); +} + +describe('ToolbarUI', () => { + let instance: ReturnType; + + beforeEach(() => { + document.body.innerHTML = ''; + instance = setup(); + }); + + describe('opening the toolbox', () => { + it('should dispatch the open event with an object payload', () => { + const onOpen = jest.fn(); + + instance.eventBus.addEventListener('ui:toolbox:open', onOpen); + + actionButtons(instance.holder)[0].click(); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect((onOpen.mock.calls[0][0] as CustomEvent).detail).toEqual({}); + }); + }); + + describe('mounting an announced widget', () => { + it('should mount the announced menu element into its actions area', () => { + const { menu } = announceBlockSettings(instance.eventBus); + + expect(instance.holder.querySelector('[role="toolbar"] [data-testid="block-settings-menu"]')).toBe(menu); + }); + + it('should mount the announced button as a control of its own', () => { + const { button } = announceBlockSettings(instance.eventBus); + + // A child of the actions container in its own right, which is what puts it in the + // roving tabindex -- nested inside the menu element it would be invisible to it. + expect(actionButtons(instance.holder)).toContain(button); + }); + + it('should keep one tab stop after an announced button joins', () => { + announceBlockSettings(instance.eventBus); + + const buttons = actionButtons(instance.holder); + + expect(buttons).toHaveLength(2); + expect(buttons.filter(button => button.tabIndex === 0)).toHaveLength(1); + }); + + it('should let the arrow keys reach an announced button', () => { + const { button } = announceBlockSettings(instance.eventBus); + const [plusButton] = actionButtons(instance.holder); + + plusButton.focus(); + plusButton.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + })); + + expect(button.tabIndex).toBe(0); + expect(plusButton.tabIndex).toBe(-1); + }); + }); + + describe('repositioning', () => { + it('should not move while block settings are open', () => { + const moveTo = jest.spyOn(instance.plugin, 'moveTo'); + + instance.eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({})); + selectBlock(instance.eventBus, 1); + + expect(moveTo).not.toHaveBeenCalled(); + + instance.eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); + selectBlock(instance.eventBus, 1); + + expect(moveTo).toHaveBeenCalledTimes(1); + }); + }); + + describe('destroy', () => { + it('should stop reacting to block selection', () => { + const moveTo = jest.spyOn(instance.plugin, 'moveTo'); + + instance.plugin.destroy(); + + selectBlock(instance.eventBus, 1); + + // The bus outlives the toolbar, so a listener left on it would keep this instance alive + // and still moving an element that is no longer on the page. + expect(moveTo).not.toHaveBeenCalled(); + }); + + it('should stop mounting widgets announced afterwards', () => { + instance.plugin.destroy(); + + const { button } = announceBlockSettings(instance.eventBus); + + expect(button.isConnected).toBe(false); + }); + + it('should take its holder off the page', () => { + instance.plugin.destroy(); + + expect(instance.holder.isConnected).toBe(false); + }); + }); + + describe('roving tabindex', () => { + it('should keep exactly one action button in the tab order', () => { + announceBlockSettings(instance.eventBus); + + const buttons = actionButtons(instance.holder); + + expect(buttons.length).toBeGreaterThan(1); + expect(buttons.filter(button => button.tabIndex === 0)).toHaveLength(1); + expect(buttons.filter(button => button.tabIndex === -1)).toHaveLength(buttons.length - 1); + }); + + it('should wrap around a single control', () => { + const [plusButton] = actionButtons(instance.holder); + + plusButton.focus(); + plusButton.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + })); + + // With nothing else in the container the move lands back on the same control, which is + // what keeps a one-control toolbar a no-op rather than an error. + expect(plusButton.tabIndex).toBe(0); + }); + }); +}); diff --git a/packages/ui/src/Toolbar/Toolbar.ts b/packages/ui/src/Toolbar/Toolbar.ts index af005037..5a27aa2d 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -5,9 +5,11 @@ import { css } from './Toolbar.const.js'; import type { ToolboxRenderedUIEvent } from '../Toolbox/events/index.js'; import { IconPlus } from '@codexteam/icons'; import Style from './Toolbar.module.pcss'; +import ControlStyle from '../controls.module.pcss'; import { ToolbarRenderedUIEvent } from './ToolbarRenderedUIEvent.js'; import type { BlockSelectedUIEvent } from '../Blocks/events/index.js'; import { ToolboxOpenUIEvent } from '../Toolbox/events/index.js'; +import type { BlockSettingsRenderedUIEvent } from '../BlockSettings/events/index.js'; import { messages } from '../messages.js'; /** @@ -67,7 +69,7 @@ export class ToolbarUI implements EditorjsPlugin { #nodes: ToolbarNodes = { holder: make('div', Style[css.toolbar]) as HTMLDivElement, actions: make('div', Style[css.actions]) as HTMLDivElement, - plusButton: make('button', Style[css.plusButton], { + plusButton: make('button', ControlStyle['toolbar-button'], { innerHTML: IconPlus, }) as HTMLButtonElement, }; @@ -77,6 +79,12 @@ export class ToolbarUI implements EditorjsPlugin { */ #isToolboxOpen = false; + /** + * True while the Block Settings menu is open. The menu was built for one particular block, + * so the toolbar has to stay on it until the menu closes + */ + #isBlockSettingsOpen = false; + /** * Constructor function * @param args - plugin parameters @@ -96,8 +104,10 @@ export class ToolbarUI implements EditorjsPlugin { this.#subscribeToToolboxEvents(); + this.#subscribeToBlockSettingsEvents(); + this.#eventBus.addEventListener(`ui:blocks:block-selected`, (event: BlockSelectedUIEvent) => { - if (this.#isToolboxOpen) { + if (this.#isToolboxOpen || this.#isBlockSettingsOpen) { return; } @@ -137,6 +147,21 @@ export class ToolbarUI implements EditorjsPlugin { this.#nodes.actions.appendChild(toolboxElement); } + /** + * Mounts the Block Settings widget: its button among the toolbar's own controls, where being + * a direct child is what puts it in `#controls` and so in the roving tabindex, and its menu + * alongside the toolbox popover. The tab stop is reassigned afterwards, since this arrives + * after the toolbar rendered and a button carrying no `tabindex` would be a second stop + * @param button - the control that opens the menu + * @param blockSettingsElement - the element the menu renders into + */ + #addBlockSettings(button: HTMLElement, blockSettingsElement: HTMLElement): void { + this.#nodes.actions.appendChild(button); + this.#nodes.actions.appendChild(blockSettingsElement); + + this.#updateRovingTabindex(this.#nodes.plusButton); + } + /** * Creates Toolbar HTML nodes */ @@ -215,7 +240,35 @@ export class ToolbarUI implements EditorjsPlugin { * Dispatches an event to Toolbox plugin to open the toolbox */ #openToolbox(): void { - this.#eventBus.dispatchEvent(new ToolboxOpenUIEvent('ui:toolbox:open')); + /** + * The constructor takes a payload, not an event name -- the name is the class's own + * business. A string satisfied the empty payload type, so this went unnoticed and + * shipped `detail: 'ui:toolbox:open'` to every listener + */ + this.#eventBus.dispatchEvent(new ToolboxOpenUIEvent({})); + } + + /** + * Subscribes to the Block Settings plugin's events + */ + #subscribeToBlockSettingsEvents(): void { + const { signal } = this.#listenersController; + + this.#eventBus.addEventListener(`ui:block-settings:rendered`, (event: BlockSettingsRenderedUIEvent) => { + this.#addBlockSettings(event.detail.button, event.detail.blockSettings); + }, { signal }); + + /** + * The menu was built for one block, so the toolbar holds its position until it closes. + * The button's own `aria-expanded` belongs to the plugin that owns the button + */ + this.#eventBus.addEventListener(`ui:block-settings:opened`, () => { + this.#isBlockSettingsOpen = true; + }, { signal }); + + this.#eventBus.addEventListener(`ui:block-settings:closed`, () => { + this.#isBlockSettingsOpen = false; + }, { signal }); } /** diff --git a/packages/ui/src/controls.module.pcss b/packages/ui/src/controls.module.pcss new file mode 100644 index 00000000..ef0d2f58 --- /dev/null +++ b/packages/ui/src/controls.module.pcss @@ -0,0 +1,28 @@ +/** + * Styling shared by the icon buttons in the floating toolbar's actions container, which are not + * all rendered by the same component: `ToolbarUI` owns the plus button, `BlockSettingsUI` the + * settings button. A plain class rather than an `@apply` property set, because that resolves + * within one file's PostCSS pass and every component would have to repeat the declarations + */ +.toolbar-button { + background: none; + border: none; + padding: 0; + + color: var(--base--text); + cursor: pointer; + width: 26px; + height: 26px; + border-radius: var(--radius-m); + display: inline-flex; + justify-content: center; + align-items: center; + user-select: none; + flex-shrink: 0; + + @media (hover: hover) { + &:hover { + background-color: var(--accent--bg-primary); + } + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index da438244..f88cdf9f 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -119,3 +119,4 @@ export * from './InlineToolbar/InlineToolbar.js'; export * from './Blocks/Blocks.js'; export * from './Toolbox/Toolbox.js'; export * from './Toolbar/Toolbar.js'; +export * from './BlockSettings/BlockSettings.js'; diff --git a/packages/ui/src/messages.ts b/packages/ui/src/messages.ts index bc4f4856..d971ff28 100644 --- a/packages/ui/src/messages.ts +++ b/packages/ui/src/messages.ts @@ -18,6 +18,15 @@ export const messages = { /** Accessible name of the toolbox menu. Deliberately matches the button that opens it */ addBlockMenu: 'Add block', + /** + * Accessible name of the block settings menu. Deliberately matches the button that opens it, + * the same way the toolbox menu matches the add-block button + */ + blockSettingsMenu: 'Block settings', + + /** Accessible name of the icon-only button that opens the block settings menu */ + blockSettingsButton: 'Block settings', + /** Accessible name of the inline formatting toolbar, distinguishing it from the block one */ inlineToolbar: 'Text formatting', diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 52732d11..c20be1eb 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -9,6 +9,9 @@ import postcssApply from 'postcss-apply'; export default defineConfig({ plugins: [ + // `tsconfig.json` includes everything under `src`, which now matches the spec + // files too. `tsconfig.build.json` is the one that excludes them, so declaration + // emit must read that instead or `dist` grows `.d.ts` files for the tests. dts({ tsconfigPath: './tsconfig.build.json' }), // vite-plugin-css-injected-by-js caches injected CSS across rebuilds in a // persistent `vite build --watch` process, so edits always show up one diff --git a/yarn.lock b/yarn.lock index 05b73a8e..4f5a881b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2364,6 +2364,34 @@ __metadata: languageName: unknown linkType: soft +"@editorjs/default-block-settings-plugin@workspace:^, @editorjs/default-block-settings-plugin@workspace:packages/plugins/default-block-settings-plugin": + version: 0.0.0-use.local + resolution: "@editorjs/default-block-settings-plugin@workspace:packages/plugins/default-block-settings-plugin" + dependencies: + "@babel/core": "npm:^7.29.0" + "@babel/preset-env": "npm:^7.29.2" + "@codexteam/icons": "npm:^0.3.3" + "@editorjs/sdk": "workspace:^" + "@editorjs/ui": "workspace:^" + "@jest/globals": "npm:^29.7.0" + "@stryker-mutator/core": "npm:^7.0.2" + "@stryker-mutator/jest-runner": "npm:^7.0.2" + "@stryker-mutator/typescript-checker": "npm:^7.0.2" + "@types/eslint": "npm:^9.6.1" + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:^22.10.2" + babel-jest: "npm:^29.7.0" + eslint: "npm:^9.24.0" + eslint-config-codex: "npm:^2.0.3" + eslint-plugin-import: "npm:^2.31.0" + jest: "npm:^29.7.0" + stryker-cli: "npm:^1.0.2" + ts-jest: "npm:^29.2.5" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.5.4" + languageName: unknown + linkType: soft + "@editorjs/document-playground@workspace:packages/playground": version: 0.0.0-use.local resolution: "@editorjs/document-playground@workspace:packages/playground" @@ -2446,6 +2474,7 @@ __metadata: "@editorjs/clipboard-plugin": "workspace:^" "@editorjs/collaboration-manager": "workspace:^" "@editorjs/core": "workspace:^" + "@editorjs/default-block-settings-plugin": "workspace:^" "@editorjs/dom-adapters": "workspace:^" "@editorjs/inline-link": "workspace:^" "@editorjs/italic": "workspace:^" @@ -2689,6 +2718,8 @@ __metadata: version: 0.0.0-use.local resolution: "@editorjs/ui@workspace:packages/ui" dependencies: + "@babel/core": "npm:^7.29.0" + "@babel/preset-env": "npm:^7.29.2" "@codexteam/icons": "npm:^0.3.3" "@codexteam/ui": "npm:^0.2.2" "@editorjs/core": "workspace:^" @@ -2700,6 +2731,7 @@ __metadata: "@editorjs/ui-kit": "npm:^2.0.0" "@jest/globals": "npm:^29.7.0" "@types/jest": "npm:^29.5.12" + babel-jest: "npm:^29.7.0" chokidar-cli: "npm:^3.0.0" eslint: "npm:^9.9.1" eslint-config-codex: "npm:^2.0.3" @@ -4073,10 +4105,10 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.153.0": - version: 0.153.0 - resolution: "@oxc-project/types@npm:0.153.0" - checksum: b123d6c8ed40b683be9af3a976148d522c621421bbae2d5716d210cda7a858108daa4bad77450b780f63529f2c43bd00e4c179f5952ffb6abbaacebdc71e282f +"@oxc-project/types@npm:=0.154.0": + version: 0.154.0 + resolution: "@oxc-project/types@npm:0.154.0" + checksum: 543b06a3955f178214ff159092e3fe83a92355bc9e7e712f8992c15b5ad6149c854c495986df37a8295977ef5f27d30f36229546f913e67a72418bbade333bf3 languageName: node linkType: hard @@ -4121,107 +4153,107 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-android-arm-eabi@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.13" +"@rolldown/binding-android-arm-eabi@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.14" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-android-arm64@npm:1.2.13" +"@rolldown/binding-android-arm64@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-android-arm64@npm:1.2.14" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-darwin-arm64@npm:1.2.13" +"@rolldown/binding-darwin-arm64@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-darwin-arm64@npm:1.2.14" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-darwin-x64@npm:1.2.13" +"@rolldown/binding-darwin-x64@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-darwin-x64@npm:1.2.14" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-freebsd-x64@npm:1.2.13" +"@rolldown/binding-freebsd-x64@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-freebsd-x64@npm:1.2.14" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.13" +"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.14" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.13" +"@rolldown/binding-linux-arm64-gnu@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.14" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.13" +"@rolldown/binding-linux-arm64-musl@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.14" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.13" +"@rolldown/binding-linux-ppc64-gnu@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.14" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.13" +"@rolldown/binding-linux-s390x-gnu@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.14" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.13" +"@rolldown/binding-linux-x64-gnu@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.14" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.13" +"@rolldown/binding-linux-x64-musl@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.14" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.13" +"@rolldown/binding-openharmony-arm64@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.14" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.13" +"@rolldown/binding-win32-arm64-msvc@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.14" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.2.13": - version: 1.2.13 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.13" +"@rolldown/binding-win32-x64-msvc@npm:1.2.14": + version: 1.2.14 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.14" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -12416,25 +12448,25 @@ __metadata: linkType: hard "rolldown@npm:~1.2.12": - version: 1.2.13 - resolution: "rolldown@npm:1.2.13" - dependencies: - "@oxc-project/types": "npm:=0.153.0" - "@rolldown/binding-android-arm-eabi": "npm:1.2.13" - "@rolldown/binding-android-arm64": "npm:1.2.13" - "@rolldown/binding-darwin-arm64": "npm:1.2.13" - "@rolldown/binding-darwin-x64": "npm:1.2.13" - "@rolldown/binding-freebsd-x64": "npm:1.2.13" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.13" - "@rolldown/binding-linux-arm64-gnu": "npm:1.2.13" - "@rolldown/binding-linux-arm64-musl": "npm:1.2.13" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.13" - "@rolldown/binding-linux-s390x-gnu": "npm:1.2.13" - "@rolldown/binding-linux-x64-gnu": "npm:1.2.13" - "@rolldown/binding-linux-x64-musl": "npm:1.2.13" - "@rolldown/binding-openharmony-arm64": "npm:1.2.13" - "@rolldown/binding-win32-arm64-msvc": "npm:1.2.13" - "@rolldown/binding-win32-x64-msvc": "npm:1.2.13" + version: 1.2.14 + resolution: "rolldown@npm:1.2.14" + dependencies: + "@oxc-project/types": "npm:=0.154.0" + "@rolldown/binding-android-arm-eabi": "npm:1.2.14" + "@rolldown/binding-android-arm64": "npm:1.2.14" + "@rolldown/binding-darwin-arm64": "npm:1.2.14" + "@rolldown/binding-darwin-x64": "npm:1.2.14" + "@rolldown/binding-freebsd-x64": "npm:1.2.14" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.14" + "@rolldown/binding-linux-arm64-gnu": "npm:1.2.14" + "@rolldown/binding-linux-arm64-musl": "npm:1.2.14" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.14" + "@rolldown/binding-linux-s390x-gnu": "npm:1.2.14" + "@rolldown/binding-linux-x64-gnu": "npm:1.2.14" + "@rolldown/binding-linux-x64-musl": "npm:1.2.14" + "@rolldown/binding-openharmony-arm64": "npm:1.2.14" + "@rolldown/binding-win32-arm64-msvc": "npm:1.2.14" + "@rolldown/binding-win32-x64-msvc": "npm:1.2.14" "@rolldown/pluginutils": "npm:^1.0.0" dependenciesMeta: "@rolldown/binding-android-arm-eabi": @@ -12469,7 +12501,7 @@ __metadata: optional: true bin: rolldown: ./bin/cli.mjs - checksum: 7e602afd9c851e1e4c26711030211ec36c538f57a59e5c84ea74167a98db14eeecc8741a8c3564bcce8b9377ec0c5502973acc823bca4788da25ad6f70283416 + checksum: 8573c260516eefed8266da2dced4fa0742c8c44dbc79d35f5cc43cf1cfdaa1afedca7ab31e1abcf733be598f1866d5f8047884826717f37a507e70b4a0e510a2 languageName: node linkType: hard