Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
44769dc
refactor(model)!: rename block tunes to per-plugin block data
gohabereg Sep 25, 2026
4a68df3
feat(sdk): expose typed per-block plugin data
gohabereg Sep 25, 2026
c8a1b90
feat(core): read and write per-block plugin data through BlocksAPI
gohabereg Sep 25, 2026
273ca74
feat(collaboration-manager): synchronize per-block plugin data
gohabereg Sep 25, 2026
568b6a5
test: kill surviving mutants in the plugin data paths
gohabereg Sep 25, 2026
12573ee
feat(core): carry v2 tune data into plugin data on conversion
gohabereg Sep 25, 2026
eb540c6
docs: describe per-block plugin data in place of block tunes
gohabereg Sep 26, 2026
b62d820
docs(openspec): check off the implemented plugin-block-data tasks
gohabereg Sep 26, 2026
a587f53
docs: keep the plugin-data comments to two sentences
gohabereg Oct 9, 2026
1a140c8
fix(model): keep a plugin data write that changes nothing off the wire
gohabereg Oct 10, 2026
8228e73
fix(collaboration-manager): converge concurrent plugin data writes
gohabereg Oct 11, 2026
7546fa2
docs(openspec): check off the second-round plugin-block-data follow-ups
gohabereg Oct 11, 2026
e47f784
fix(model,core): keep plugin data out of reach of Object.prototype
gohabereg Oct 16, 2026
f8fa0cd
docs(openspec): check off the third-round plugin-block-data follow-ups
gohabereg Oct 16, 2026
b004126
test(collaboration-manager): say what "not transformed" actually means
gohabereg Oct 17, 2026
92c0657
fix(model): copy plugin data values in and out of the store
gohabereg Nov 5, 2026
6121800
fix(collaboration-manager): drop undo entries a collaborator overwrote
gohabereg Nov 5, 2026
384329c
docs(openspec): specify and check off the fourth-round plugin-block-d…
gohabereg Nov 5, 2026
9b749a4
test(model): kill the surviving mutants in the plugin data copy paths
gohabereg Nov 5, 2026
a268f2c
fix(collaboration-manager): keep a sent operation pending until it is…
gohabereg Nov 8, 2026
99dc155
docs(openspec): specify and check off the fifth-round plugin-block-da…
gohabereg Nov 8, 2026
7e001ac
refactor!: remove the Block Tune tool kind
gohabereg Sep 26, 2026
111c671
docs: drop the Block Tune tool kind
gohabereg Sep 26, 2026
56a3f4e
test(core): cover the tool routing the tune case was removed from
gohabereg Sep 26, 2026
f484edd
docs(openspec): check off the implemented remove-block-tunes tasks
gohabereg Sep 26, 2026
e2760fa
docs(sdk): stop MenuConfig describing a contract that no longer exists
gohabereg Oct 10, 2026
f5e0ccb
test(ui): give the package a Jest harness
gohabereg Oct 9, 2026
97ec4f2
feat(ui): add the block settings menu plugin
gohabereg Oct 9, 2026
ff2a69e
feat(ui): open block settings from the toolbar
gohabereg Oct 9, 2026
f92a9df
feat(default-block-settings-plugin): move, move and delete as a plugin
gohabereg Oct 9, 2026
227eece
feat(editorjs): ship the block settings menu in the bundle
gohabereg Oct 9, 2026
596a97d
fix(ui): let block settings rows fill the menu
gohabereg Oct 9, 2026
36fab79
test(editorjs): cover the block settings menu's accessibility
gohabereg Oct 9, 2026
40b5314
fix(ui): make the block settings menu robust to its own providers
gohabereg Oct 9, 2026
451a090
test(editorjs): group the block settings e2e tests
gohabereg Oct 9, 2026
43ff8ae
refactor(ui)!: let the block settings plugin own its button
gohabereg Oct 9, 2026
451120d
refactor(ui): share the toolbar button styling
gohabereg Oct 9, 2026
d849eea
docs: keep the block settings comments to two sentences
gohabereg Oct 9, 2026
a65fc07
fix(ci): build type-only workspace dependencies before linting and te…
gohabereg Oct 9, 2026
c777990
fix(ui): make the block settings button reachable, dismissable and cheap
gohabereg Oct 10, 2026
0617449
docs(openspec): check off the block-settings-ui review follow-ups
gohabereg Oct 10, 2026
e11ac04
fix(ui): stop one settings provider from taking the whole menu with it
gohabereg Oct 11, 2026
5ec220c
fix(ui): keep the settings menu pointing at the block it was built for
gohabereg Oct 16, 2026
c8be3b8
docs(openspec): check off the third-round block-settings-ui follow-ups
gohabereg Oct 16, 2026
af13270
fix(ui): keep the block settings target with the button
gohabereg Nov 5, 2026
8038f1e
fix(ui): isolate a throwing isActive to the provider that shipped it
gohabereg Nov 5, 2026
4c9013d
docs(openspec): specify and check off the fourth-round block-settings…
gohabereg Nov 5, 2026
baee95e
fix(ui): dismiss the block settings menu from a second Enter
gohabereg Nov 8, 2026
3a4d81a
docs(openspec): specify and check off the fifth-round block-settings-…
gohabereg Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions .github/actions/base-coverage/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/agents/docs-updater.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/default-block-settings-plugin.yml
Original file line number Diff line number Diff line change
@@ -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 }}
10 changes: 10 additions & 0 deletions .github/workflows/ui.yml
Original file line number Diff line number Diff line change
@@ -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'
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/diagrams/architecture-overview.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
4 changes: 2 additions & 2 deletions docs/diagrams/events-catalog.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -163,7 +163,7 @@ classDiagram
DataNodeAddedEvent --|> BaseDocumentEvent
DataNodeRemovedEvent --|> BaseDocumentEvent
ValueModifiedEvent --|> BaseDocumentEvent
TuneModifiedEvent --|> BaseDocumentEvent
PluginDataModifiedEvent --|> BaseDocumentEvent
PropertyModifiedEvent --|> BaseDocumentEvent

BlockAddedCoreEvent --|> CoreEventBase
Expand Down
11 changes: 6 additions & 5 deletions docs/diagrams/model-tree-structure.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand Down Expand Up @@ -123,7 +124,7 @@ classDiagram
EditorJSModel --|> EventBus
EditorDocument --|> EventBus
BlockNode --|> EventBus
BlockTune --|> EventBus
PluginDataNode --|> EventBus
ValueNode --|> EventBus
ParentInlineNode --|> EventBus

Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/diagrams/plugin-lifecycle-flow.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
19 changes: 17 additions & 2 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand All @@ -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 |
Expand All @@ -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
Expand Down
26 changes: 13 additions & 13 deletions docs/index-serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<tuneName>", "key": "<tuneKey>", "id": "<documentId>" }
{ "k": "plugin", "b": 2, "plugin": "<pluginName>", "key": "<pluginKey>", "id": "<documentId>" }
```

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

---

Expand Down Expand Up @@ -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`
Expand Down
6 changes: 3 additions & 3 deletions docs/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Each `BlockNode` contains keyed data nodes:

- `TextNode`: rich text with inline tree (`FormattingInlineNode` + `TextInlineNode`).
- `ValueNode<T>`: 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

Expand Down Expand Up @@ -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
```

Expand All @@ -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
```
Expand Down
Loading
Loading