From 44769dcda8c4def19e99a2033fb878229f3da166 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 00:17:30 +0100 Subject: [PATCH 01/49] refactor(model)!: rename block tunes to per-plugin block data Renames the model's tune vocabulary so per-block extra data belongs to plugins rather than to a dedicated Block Tune entity: - BlockTune entity -> PluginDataNode, with key deletion when a value is set to undefined and an isEmpty getter - TuneIndex -> PluginDataIndex, serialized as {"k":"plugin",...,"plugin":...} - TuneModifiedEvent -> PluginDataModifiedEvent, BlockTuneEvents -> PluginDataEvents - BlockTuneName/BlockTuneSerialized -> PluginDataName/PluginDataSerialized - the serialized block key `tunes` -> `plugins` Behavior changes beyond the rename: - BlockNode.updatePluginData creates a missing entry instead of throwing. The node is created empty and its listener attached before the first update, because PluginDataNode dispatches synchronously. - entries live in a null-prototype record, and an empty plugin name throws, so a document-supplied name like __proto__ is stored as plain data - empty entries are left out of serialization - EditorDocument.modifyData applies a Modified change addressed by a PluginDataIndex, so undo/redo and remote operations can replay plugin data - the bubbled event carries the acting user from the context instead of a hardcoded 'user' BREAKING CHANGE: the serialized block key `tunes` is now `plugins`, and the serialized index kind "tune" is now "plugin". Co-Authored-By: Claude Opus 5 --- packages/model-types/src/BlockNode.ts | 8 +- packages/model-types/src/BlockTune.ts | 13 -- packages/model-types/src/EventMap.ts | 8 +- packages/model-types/src/Index/Index.spec.ts | 77 ++++++--- packages/model-types/src/Index/IndexBase.ts | 14 +- .../model-types/src/Index/PartialIndex.ts | 44 ++--- .../model-types/src/Index/PluginDataIndex.ts | 99 +++++++++++ packages/model-types/src/Index/TuneIndex.ts | 99 ----------- packages/model-types/src/Index/index.ts | 28 +-- packages/model-types/src/PluginData.ts | 13 ++ .../src/events/PluginDataModifiedEvent.ts | 20 +++ .../src/events/TuneModifiedEvent.ts | 20 --- packages/model-types/src/events/index.ts | 2 +- packages/model-types/src/index.ts | 14 +- packages/model/src/EditorJSModel.spec.ts | 2 +- packages/model/src/EditorJSModel.ts | 12 +- .../BlockNode/BlockNode.integration.spec.ts | 97 ++++++++++- .../src/entities/BlockNode/BlockNode.spec.ts | 149 +++++++++------- .../src/entities/BlockNode/__mocks__/index.ts | 2 +- .../model/src/entities/BlockNode/index.ts | 121 ++++++++----- .../types/BlockNodeConstructorParameters.ts | 6 +- .../src/entities/BlockTune/BlockTune.spec.ts | 106 ------------ .../src/entities/BlockTune/__mocks__/index.ts | 22 --- .../model/src/entities/BlockTune/index.ts | 63 ------- .../types/BlockTuneConstructorParameters.ts | 13 -- .../src/entities/BlockTune/types/index.ts | 1 - .../EditorDocument/EditorDocument.spec.ts | 58 +++---- .../src/entities/EditorDocument/index.ts | 21 ++- .../PluginDataNode/PluginDataNode.spec.ts | 160 ++++++++++++++++++ .../PluginDataNode/__mocks__/index.ts | 27 +++ .../src/entities/PluginDataNode/index.ts | 83 +++++++++ .../PluginDataNodeConstructorParameters.ts | 13 ++ .../entities/PluginDataNode/types/index.ts | 1 + packages/model/src/entities/index.ts | 2 +- 34 files changed, 844 insertions(+), 574 deletions(-) delete mode 100644 packages/model-types/src/BlockTune.ts create mode 100644 packages/model-types/src/Index/PluginDataIndex.ts delete mode 100644 packages/model-types/src/Index/TuneIndex.ts create mode 100644 packages/model-types/src/PluginData.ts create mode 100644 packages/model-types/src/events/PluginDataModifiedEvent.ts delete mode 100644 packages/model-types/src/events/TuneModifiedEvent.ts delete mode 100644 packages/model/src/entities/BlockTune/BlockTune.spec.ts delete mode 100644 packages/model/src/entities/BlockTune/__mocks__/index.ts delete mode 100644 packages/model/src/entities/BlockTune/index.ts delete mode 100644 packages/model/src/entities/BlockTune/types/BlockTuneConstructorParameters.ts delete mode 100644 packages/model/src/entities/BlockTune/types/index.ts create mode 100644 packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts create mode 100644 packages/model/src/entities/PluginDataNode/__mocks__/index.ts create mode 100644 packages/model/src/entities/PluginDataNode/index.ts create mode 100644 packages/model/src/entities/PluginDataNode/types/PluginDataNodeConstructorParameters.ts create mode 100644 packages/model/src/entities/PluginDataNode/types/index.ts 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..569b7744 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts @@ -1,4 +1,4 @@ -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'; @@ -237,4 +237,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__'), { id: 'intro' }); + blockNode.updatePluginData(createPluginDataName('toString'), { id: 'other' }); + + expect(blockNode.serialized.plugins) + .toEqual({ '__proto__': { id: 'intro' }, + 'toString': { 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__': { id: 'intro' } } }); + + expect(blockNode.serialized.plugins) + .toEqual({ '__proto__': { 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..6ec8095f 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,20 @@ describe('BlockNode', () => { }); }); - describe('.updateTuneData()', () => { + describe('.updatePluginData()', () => { afterEach(() => { jest.clearAllMocks(); }); - it('should call .update() method of the BlockTune', () => { - const blockTuneName = 'align' as BlockTuneName; + 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 +877,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 +1704,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 +1717,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 +1729,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..da763926 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'; @@ -55,7 +55,7 @@ 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. + * It can also carry per-plugin data, which plugins use to store their own state for the block. */ export class BlockNode extends EventBus { /** @@ -79,9 +79,13 @@ 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: names come from documents, and a plain assignment of a + * `__proto__` key to an ordinary object creates no own property at all, which would silently + * lose that plugin's data. With no prototype every name is stored as plain data. */ - #tunes: Record; + #plugins: Record = Object.create(null) as Record; /** * Constructor for BlockNode class. @@ -90,33 +94,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 +144,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 +159,21 @@ export class BlockNode extends EventBus { entry => this.#serializeData(entry) ); - const serializedTunes = mapObject( - this.#tunes, - tune => tune.serialized - ); + const serializedPlugins: Record = {}; + + Object.entries(this.#plugins).forEach(([pluginName, pluginData]) => { + if (pluginData.isEmpty) { + return; + } + + serializedPlugins[pluginName] = pluginData.serialized; + }); return { id: this.#id, name: this.#name, data: serializedData, - tunes: serializedTunes, + plugins: serializedPlugins, }; } @@ -256,14 +255,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 +591,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 + */ + #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 */ - #listenAndBubbleTuneEvent(tune: BlockTune, name: BlockTuneName): void { - tune.addEventListener( + #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..055b3434 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'); @@ -1707,11 +1707,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 +1722,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 +1749,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..ccc9a9ab --- /dev/null +++ b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts @@ -0,0 +1,160 @@ +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('.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('.serialized', () => { + 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..6f7a0c0c --- /dev/null +++ b/packages/model/src/entities/PluginDataNode/index.ts @@ -0,0 +1,83 @@ +import { getContext } from '../../utils/Context.js'; +import type { PluginDataNodeConstructorParameters } from './types/index.js'; +import { + PartialIndex, + type PluginDataSerialized, + type PluginDataName, + EventBus, + PluginDataModifiedEvent +} from '@editorjs/model-types'; + +/** + * 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; + this.#data = 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]; + + if (value === undefined) { + delete this.#data[key]; + } else { + this.#data[key] = value; + } + + this.dispatchEvent( + new PluginDataModifiedEvent(new PartialIndex({ pluginKey: key }), { + value, + previous: previousValue, + }, getContext()!) + ); + } + + /** + * Returns serialized version of the plugin's data. + */ + public get serialized(): PluginDataSerialized { + return 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'; From 4a68df3b9f97d5128999d4e9f3e3be3183936715 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 00:18:59 +0100 Subject: [PATCH 02/49] feat(sdk): expose typed per-block plugin data - re-export the renamed model types (PluginDataName, PluginDataSerialized, PluginDataIndex, PluginDataModifiedEvent, createPluginDataName) - declare EditorjsPluginDataMap, a third augmentable map alongside EditorjsPluginApiMap and ToolPluginOptionsMap, and resolve a plugin's data shape through PluginDataFor. An undeclared id falls back to a plain record rather than never, since per-block data is often stored by a plugin that has no reason to publish its shape. - add BlocksAPI.getPluginData / updatePluginData and a `plugins` map on insert Co-Authored-By: Claude Opus 5 --- packages/sdk/src/api/BlocksAPI.ts | 44 +++++++++++++++++++++++++ packages/sdk/src/index.ts | 42 +++++++++++++++++++---- packages/sdk/src/pluginTypeMaps.spec.ts | 42 ++++++++++++++++++++++- 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 65b4c78e..a24626ed 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; @@ -216,6 +221,45 @@ 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 plugin's + * entry when the block has none yet. A key set to `undefined` is removed. + * + * Each key is recorded as its own modification, so a call that writes 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. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 31b3de42..cc6a0b10 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -32,6 +32,36 @@ 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. + * + * Falls back to a plain record for an id absent from {@link EditorjsPluginDataMap}: unlike a + * plugin's public API, per-block data is often written by a plugin that has no reason to publish + * its shape, so an undeclared id stays usable instead of resolving to `never`. + * + * The check is deliberately non-distributive (`[Id] extends [...]`), because the default + * {@link PluginId} is a union including `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 +69,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 +99,8 @@ export type { BlockNodeInit, BlockNodeSerialized, BlockToolName, - BlockTuneName, - BlockTuneSerialized, + PluginDataName, + PluginDataSerialized, TextRange, InlineFragment, InlineTreeNodeSerialized, @@ -102,7 +132,7 @@ export { generateBlockId, createBlockToolName, createDataKey, - createBlockTuneName, + createPluginDataName, createInlineToolName, createInlineToolData, EventAction, @@ -122,13 +152,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 = { From c8a1b90ce6a4014faff07c7041705b13535a96fb Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 00:26:24 +0100 Subject: [PATCH 03/49] feat(core): read and write per-block plugin data through BlocksAPI - BlocksAPI.getPluginData / updatePluginData, backed by BlocksManager, with the acting user defaulting to the configured one - insert() accepts a `plugins` map, and convert() carries plugin data over to the converted block - fix insert() dropping the block's data: BlocksManager spread `...data` into the block init while BlockNode reads a nested `data` key, so inserting with data silently produced an empty block. `plugins` is passed as its own key. - fix BlocksAPI.insert not forwarding `focus`, `plugins` and `userId` - add UndoRedoManager integration tests over a real model: undoing a first write removes the entry, redo restores it, and undoing one key leaves the plugin's other keys untouched - fix the BlocksAPI integration spec wiring a second, empty model into the API, which made every direct model read in it see an empty document Co-Authored-By: Claude Opus 5 --- .../src/api/BlocksAPI.integration.spec.ts | 100 +++++++++++++++++- packages/core/src/api/BlocksAPI.spec.ts | 6 ++ packages/core/src/api/BlocksAPI.ts | 27 ++++- .../core/src/components/BlockManager.spec.ts | 1 + packages/core/src/components/BlockManager.ts | 38 ++++++- .../UndoRedoManager.integration.spec.ts | 75 +++++++++++++ 6 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/components/UndoRedoManager.integration.spec.ts diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index 317b8297..dcd17300 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -63,10 +63,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 ); }); @@ -1183,4 +1187,98 @@ 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 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 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..29afb914 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,26 @@ export class BlocksAPI implements BlocksApiInterface { }: Parameters[0]): void { this.#blocksManager.convertBlock(block as BlockIndexOrId, createDataKey(key), newType, userId, dataOverrides); } + + /** + * 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..69ca00de 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 {}, diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index ee0f3a47..e5e4c4b3 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,37 @@ export class BlocksManager { this.#model.addBlock(userId, { name: newType, data: finalData, + ...(block.plugins !== undefined && { plugins: block.plugins }), }, blockIndex); } + /** + * 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); + + return this.#model.getBlockSerialized(blockIndex).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..7671cce5 --- /dev/null +++ b/packages/core/src/components/UndoRedoManager.integration.spec.ts @@ -0,0 +1,75 @@ +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 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' }); + }); +}); From 273ca741e63f83c308518052300627b2acc5bf9c Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 00:35:45 +0100 Subject: [PATCH 04/49] feat(collaboration-manager): synchronize per-block plugin data - map PluginDataModifiedEvent to a Modify operation carrying the new and previous values, instead of logging it as an unknown event type - add a PluginDataIndex branch to OperationsTransformer#applyTransformation, which dispatches on the against-operation's index kind and previously threw 'Unsupported index type'. Without it the first remote plugin data change threw in OTClient's pending-operation reduce, in transformStacks for every remote event, and in ot-server's conflicting-operation reduce. - widen ModifyOperationData's payload from Record to unknown, so a scalar plugin data value is representable, and narrow the two payload length reads that relied on the old type - move the ot-server fixtures off the `tunes` key Two concurrent writes to the same key stay untransformed: server order decides and the last applied one wins. Co-Authored-By: Claude Opus 5 --- .../src/CollaborationManager.spec.ts | 113 ++++++++++++++---- .../src/CollaborationManager.ts | 7 ++ .../collaboration-manager/src/Operation.ts | 17 ++- .../src/OperationsTransformer.spec.ts | 95 ++++++++++++++- .../src/OperationsTransformer.ts | 26 +++- .../ot-server/src/DocumentManager.spec.ts | 8 +- 6 files changed, 228 insertions(+), 38 deletions(-) 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.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..b36555f5 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,95 @@ 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); + }); + + it('should not be transformed against another plugin data operation', () => { + const operation = pluginOp(2); + const againstOp = pluginOp(2); + + const result = transformer.transform(operation, againstOp); + + expect(result.type).toBe(OperationType.Modify); + expect((result.index as PluginDataIndex).blockIndex).toBe(2); + }); + + 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..375883a0 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,22 @@ export class OperationsTransformer { } } + /** + * Method that transforms operation against a plugin data operation + * + * Plugin data operations only ever modify one key of one plugin's per-block data, so they + * cannot shift another operation's position. Two concurrent writes to the same key are left + * untransformed on purpose: the server's order decides, and the last applied one wins. + * @param operation - Operation to be transformed + * @param againstOp - Operation against which the current operation should be transformed + * @returns copy of the current operation + */ + #transformAgainstPluginDataOperation(operation: Operation, againstOp: Operation): Operation { + void againstOp; + + return Operation.from(operation); + } + /** * Method that transforms operation against data (value) operation * @@ -264,7 +284,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 +372,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/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', From 568b6a502153e7ddbba5523316919e80219b8b78 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 00:50:21 +0100 Subject: [PATCH 05/49] test: kill surviving mutants in the plugin data paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutation testing (scoped to the changed files, as CI does) pointed at four real gaps: - BlocksManager.insert: nothing asserted that block data is nested under a `data` key rather than spread, nor that the plugins map is forwarded and omitted when absent - convertBlock: nothing covered carrying plugin data to the converted block - EditorDocument.modifyData: the PluginDataIndex branch had no model-level test - BlockNode: the initial-data path of plugin node creation was unasserted Fixing the last one surfaced a bug in BlockNode.serialized: it assigned entries into a plain object, so a plugin named `__proto__` set the object's prototype instead of becoming a key and vanished from the serialized block. The entry map is now built with Object.fromEntries. The previous test only passed because its expected literal was broken the same way — writing the key as a computed property, to satisfy the naming-convention lint rule, is what exposed it. Scores for the changed files: model 90.38 (threshold 75), core 86.72, collaboration-manager 52.08 (threshold 0, unchanged file-level scores). No surviving mutants remain on lines this branch touched. Co-Authored-By: Claude Opus 5 --- .../core/src/components/BlockManager.spec.ts | 98 +++++++++++++++++++ .../BlockNode/BlockNode.integration.spec.ts | 19 ++-- .../src/entities/BlockNode/BlockNode.spec.ts | 18 ++++ .../model/src/entities/BlockNode/index.ts | 19 ++-- .../EditorDocument/EditorDocument.spec.ts | 28 ++++++ 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/packages/core/src/components/BlockManager.spec.ts b/packages/core/src/components/BlockManager.spec.ts index 69ca00de..1a94a2f7 100644 --- a/packages/core/src/components/BlockManager.spec.ts +++ b/packages/core/src/components/BlockManager.spec.ts @@ -101,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, @@ -727,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/model/src/entities/BlockNode/BlockNode.integration.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts index 569b7744..a6ce4ff0 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.integration.spec.ts @@ -4,6 +4,13 @@ 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'; @@ -307,22 +314,22 @@ describe('BlockNode integration tests', () => { it('should store data for a plugin named like an object prototype member', () => { const blockNode = new BlockNode({ name: 'blockNode' }); - blockNode.updatePluginData(createPluginDataName('__proto__'), { id: 'intro' }); - blockNode.updatePluginData(createPluginDataName('toString'), { id: 'other' }); + blockNode.updatePluginData(createPluginDataName(PROTO_NAME), { id: 'intro' }); + blockNode.updatePluginData(createPluginDataName(TO_STRING_NAME), { id: 'other' }); expect(blockNode.serialized.plugins) - .toEqual({ '__proto__': { id: 'intro' }, - 'toString': { id: 'other' } }); + .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__': { id: 'intro' } } }); + plugins: { [PROTO_NAME]: { id: 'intro' } } }); expect(blockNode.serialized.plugins) - .toEqual({ '__proto__': { id: 'intro' } }); + .toEqual({ [PROTO_NAME]: { id: 'intro' } }); }); it('should throw when the plugin data name is empty', () => { diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index 6ec8095f..680f1a35 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -854,6 +854,24 @@ describe('BlockNode', () => { }); }); + describe('constructing with plugin data', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + 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(); diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index da763926..b44ad79a 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -159,15 +159,16 @@ export class BlockNode extends EventBus { entry => this.#serializeData(entry) ); - const serializedPlugins: Record = {}; - - Object.entries(this.#plugins).forEach(([pluginName, pluginData]) => { - if (pluginData.isEmpty) { - return; - } - - serializedPlugins[pluginName] = pluginData.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, diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index 055b3434..42b7cad1 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -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()', () => { From 12573eed690d2861b0b6b122cd8ad12390d07f21 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 00:58:52 +0100 Subject: [PATCH 06/49] feat(core): carry v2 tune data into plugin data on conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composeDataFromVersion2 dropped each v2 block's `tunes` silently. It now maps them onto the v3 `plugins` key: - names are kept verbatim, so a v3 plugin that adopts a v2 tune's name finds its data without a rename table - object data is copied key by key - a primitive, an array or null goes under the `value` key, since a plugin entry is a key/value map while v2 tune data is any JSON value - a block with no tunes, or an empty map, produces no `plugins` key - the entry map is built with Object.fromEntries, so a tune named `__proto__` survives instead of setting a prototype Adds the file's first spec, including the converter's existing data handling, and an integration test taking a v2 document through the converter into the model and reading it back with api.blocks.getPluginData. The spec stubs DOMParser, which the pre-existing inline-fragment extraction needs and this package's node test environment lacks. That extraction stays untested, so the file's mutation score is low (25%, threshold 0) — it simply had no spec at all before. Co-Authored-By: Claude Opus 5 --- .../src/api/BlocksAPI.integration.spec.ts | 29 +++ .../src/utils/composeDataFromVersion2.spec.ts | 172 ++++++++++++++++++ .../core/src/utils/composeDataFromVersion2.ts | 38 +++- 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/utils/composeDataFromVersion2.spec.ts diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index dcd17300..062cc556 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; @@ -1268,6 +1270,33 @@ describe('BlocksAPI integration (real model, mocked tools)', () => { 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' } } }); 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..1732637f 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,35 @@ 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. + * + * Names are kept verbatim: a v3 plugin that replaces a v2 tune is expected to adopt that tune's + * name, which is also what makes the stored data match. Object data is copied key by key; anything + * else (a primitive, an array, null) goes under `value`, because 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 +126,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) From eb540c6afa69d4a191699cfb12c1950fadffdd87 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 01:04:11 +0100 Subject: [PATCH 07/49] docs: describe per-block plugin data in place of block tunes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model.md, events.md, index-serialization.md: PluginDataNode, PluginDataIndex (`k: "plugin"` with `plugin`/`key` fields), PluginDataModifiedEvent, and Index.pluginData - plugins.md: a new "Per-block plugin data" section for plugin authors — the two API calls, the EditorjsPluginDataMap augmentation, and the behaviors that bite (key removal, per-key undo granularity, last-writer-wins on the same key, preserved data for unregistered plugins, move/convert/split, v2 carry over) — plus the two new api.blocks rows and the third type map - diagrams: model-tree-structure, events-catalog, architecture-overview - docs-updater agent: map the renamed model entity to docs/model.md Mentions of the Block Tune *tool kind* stay: they belong to remove-block-tunes. Co-Authored-By: Claude Opus 5 --- .github/agents/docs-updater.agent.md | 2 +- docs/diagrams/architecture-overview.mmd | 2 +- docs/diagrams/events-catalog.mmd | 4 +- docs/diagrams/model-tree-structure.mmd | 11 +++--- docs/events.md | 4 +- docs/index-serialization.md | 26 ++++++------- docs/model.md | 6 +-- docs/plugins.md | 49 +++++++++++++++++++++++-- 8 files changed, 74 insertions(+), 30 deletions(-) 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/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/events.md b/docs/events.md index 7e740804..635923e2 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 | 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..30722b3b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -114,11 +114,52 @@ Merging is shallow **at the plugin-id level**: a slice supplied through `use()` const { shortcut } = toolFacade.pluginOptions('shortcuts') ?? {}; ``` +## 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 +308,8 @@ 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 | +| `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` From b62d8208ea96cdb03aae6b835bdffecb4da23cfb Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 01:04:35 +0100 Subject: [PATCH 08/49] docs(openspec): check off the implemented plugin-block-data tasks Every task is done except the final one, which is the ready-to-archive signal: leaving it unchecked keeps the archive gate quiet until a maintainer comments /archive on the PR, per CONTRIBUTING. Co-Authored-By: Claude Opus 5 --- openspec/changes/plugin-block-data/tasks.md | 72 ++++++++++----------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/openspec/changes/plugin-block-data/tasks.md b/openspec/changes/plugin-block-data/tasks.md index 12e0dbc0..824d018a 100644 --- a/openspec/changes/plugin-block-data/tasks.md +++ b/openspec/changes/plugin-block-data/tasks.md @@ -1,56 +1,56 @@ ## 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 From a587f533743d729b1dfc340407d82a307b06dc63 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 23:47:52 +0100 Subject: [PATCH 09/49] docs: keep the plugin-data comments to two sentences Six comments added by this change ran to three. Same content, tightened: the v2 tune mapping, the BlockNode class doc and its `#plugins` field, the `updatePluginData` contract and `PluginDataFor`'s fallback. Co-Authored-By: Claude Opus 5 --- packages/core/src/utils/composeDataFromVersion2.ts | 8 +++----- packages/model/src/entities/BlockNode/index.ts | 12 +++++------- packages/sdk/src/api/BlocksAPI.ts | 8 +++----- packages/sdk/src/index.ts | 14 +++++--------- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/packages/core/src/utils/composeDataFromVersion2.ts b/packages/core/src/utils/composeDataFromVersion2.ts index 1732637f..0660dcbb 100644 --- a/packages/core/src/utils/composeDataFromVersion2.ts +++ b/packages/core/src/utils/composeDataFromVersion2.ts @@ -92,11 +92,9 @@ function extractFragments(html: string): InlineFragment[] { const SCALAR_TUNE_KEY = 'value'; /** - * Maps a v2 block's tunes onto v3 per-plugin data. - * - * Names are kept verbatim: a v3 plugin that replaces a v2 tune is expected to adopt that tune's - * name, which is also what makes the stored data match. Object data is copied key by key; anything - * else (a primitive, an array, null) goes under `value`, because a plugin entry is a key/value map. + * 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 { diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index b44ad79a..44c08912 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -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 carry per-plugin data, which plugins use to store their own state for the block. + * 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,11 +79,9 @@ export class BlockNode extends EventBus { #parent: EditorDocument | null; /** - * Per-plugin data of this block, keyed by plugin name. - * - * The record has a null prototype: names come from documents, and a plain assignment of a - * `__proto__` key to an ordinary object creates no own property at all, which would silently - * lose that plugin's data. With no prototype every name is stored as plain data. + * 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. */ #plugins: Record = Object.create(null) as Record; diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index a24626ed..6e1e1281 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -238,11 +238,9 @@ export interface BlocksAPI { }): PluginDataFor | undefined; /** - * Merges the passed keys into the per-block data of the given plugin, creating the plugin's - * entry when the block has none yet. A key set to `undefined` is removed. - * - * Each key is recorded as its own modification, so a call that writes several keys is not a - * single undo step. + * 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` diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index cc6a0b10..244e60f2 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -48,15 +48,11 @@ export interface ToolPluginOptionsMap {} export interface EditorjsPluginDataMap {} /** - * Shape of the per-block data stored by the plugin with the given id. - * - * Falls back to a plain record for an id absent from {@link EditorjsPluginDataMap}: unlike a - * plugin's public API, per-block data is often written by a plugin that has no reason to publish - * its shape, so an undeclared id stays usable instead of resolving to `never`. - * - * The check is deliberately non-distributive (`[Id] extends [...]`), because the default - * {@link PluginId} is a union including `string & {}` and a distributive conditional would - * collapse a declared id onto the fallback. + * 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] From 1a140c87b85d397e34a1cca59dbb6c4e322d6c4d Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 10 Oct 2026 01:00:16 +0100 Subject: [PATCH 10/49] fix(model): keep a plugin data write that changes nothing off the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PluginDataNode.update` dispatched unconditionally, carried over from `BlockTune`, where it was nearly harmless because tunes were rarely written. `updatePluginData` is now a public API that plugins call on every toggle, and each event becomes an operation for collaborators and a step on the undo stack — so re-applying the state a plugin just read filled the undo stack with entries that undo nothing. Storing the value a key already holds, or removing a key that is not there, now returns before dispatching. The removal case needs the extra clause: both sides are `undefined` whether the key is absent or present holding `undefined`, and only the latter is a real change. Also locks down the wire format for a removal, which has no test today: the payload is `undefined`, `JSON.stringify` drops the key outright, and the removal survives only because reading a missing key gives `undefined` back. Co-Authored-By: Claude Opus 5 --- openspec/changes/plugin-block-data/tasks.md | 4 +- .../src/Operation.spec.ts | 42 ++++++++++- .../UndoRedoManager.integration.spec.ts | 15 ++++ .../PluginDataNode/PluginDataNode.spec.ts | 72 +++++++++++++++++++ .../src/entities/PluginDataNode/index.ts | 13 ++++ 5 files changed, 143 insertions(+), 3 deletions(-) diff --git a/openspec/changes/plugin-block-data/tasks.md b/openspec/changes/plugin-block-data/tasks.md index 824d018a..32a95d08 100644 --- a/openspec/changes/plugin-block-data/tasks.md +++ b/openspec/changes/plugin-block-data/tasks.md @@ -55,8 +55,8 @@ ## 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) diff --git a/packages/collaboration-manager/src/Operation.spec.ts b/packages/collaboration-manager/src/Operation.spec.ts index 87bf21c1..f3ca029d 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,44 @@ describe('Operation', () => { expect(() => op.inverse()).toThrow('Unsupported operation type'); }); }); + describe('.serialize()', () => { + /** + * A key removal is a Modify whose payload is `undefined`, and `JSON.stringify` drops a key + * holding `undefined` entirely. It survives only because reading a key that is not there + * gives `undefined` back -- which is worth an assertion, since nothing in the types says so. + */ + it('should carry a plugin data removal through the wire', () => { + 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 carry the plugin data index itself through the wire', () => { + 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/core/src/components/UndoRedoManager.integration.spec.ts b/packages/core/src/components/UndoRedoManager.integration.spec.ts index 7671cce5..5769c833 100644 --- a/packages/core/src/components/UndoRedoManager.integration.spec.ts +++ b/packages/core/src/components/UndoRedoManager.integration.spec.ts @@ -64,6 +64,21 @@ describe('UndoRedoManager integration with plugin data (real model)', () => { 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 }); diff --git a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts index ccc9a9ab..5e2fb930 100644 --- a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts +++ b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts @@ -115,6 +115,78 @@ describe('PluginDataNode', () => { }); }); + 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(); + }); + + 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); diff --git a/packages/model/src/entities/PluginDataNode/index.ts b/packages/model/src/entities/PluginDataNode/index.ts index 6f7a0c0c..1da9e1d0 100644 --- a/packages/model/src/entities/PluginDataNode/index.ts +++ b/packages/model/src/entities/PluginDataNode/index.ts @@ -60,6 +60,19 @@ export class PluginDataNode extends EventBus { 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. + * + * 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 (Object.is(previousValue, value) && (value !== undefined || !(key in this.#data))) { + return; + } + if (value === undefined) { delete this.#data[key]; } else { From 8228e736f3ae5ada45d0200f39334dad5a3f89be Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sun, 11 Oct 2026 21:44:24 +0100 Subject: [PATCH 11/49] fix(collaboration-manager): converge concurrent plugin data writes Two clients writing the same plugin data key left one of them on a value the server does not hold. Remote operations are transformed against the pending local ones, and the plugin data path returned them untouched, so the client with an unsent write applied the incoming value over its own -- and since its operation is never echoed back to it, nothing restored what the server ended up with. The rule is now the one #transformAgainstDataOperation already applies to data values: an against-operation with no revision counts as the later of the two, and the operation being transformed becomes Neutral. It is scoped to the same block, plugin and key, since nothing else describes the same value. Co-Authored-By: Claude Opus 5 --- .../src/OperationsTransformer.spec.ts | 60 ++++++++++++++++++- .../src/OperationsTransformer.ts | 47 +++++++++++++-- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/packages/collaboration-manager/src/OperationsTransformer.spec.ts b/packages/collaboration-manager/src/OperationsTransformer.spec.ts index b36555f5..ced62bfe 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.spec.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.spec.ts @@ -595,7 +595,26 @@ describe('OperationsTransformer', () => { expect(transformer.transform(operation, againstOp).type).toBe(OperationType.Neutral); }); - it('should not be transformed against another plugin data operation', () => { + /** + * 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); @@ -605,6 +624,45 @@ describe('OperationsTransformer', () => { expect((result.index as PluginDataIndex).blockIndex).toBe(2); }); + 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' + ); + + expect(transformer.transform(incoming, unsentLocal).type).toBe(OperationType.Modify); + }); + + 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' + ); + + expect(transformer.transform(incoming, unsentLocal).type).toBe(OperationType.Modify); + }); + + 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' + ); + + expect(transformer.transform(incoming, unsentLocal).type).toBe(OperationType.Modify); + }); + it('should transform a text operation against a plugin data operation without throwing', () => { const operation = new Operation( OperationType.Insert, diff --git a/packages/collaboration-manager/src/OperationsTransformer.ts b/packages/collaboration-manager/src/OperationsTransformer.ts index 375883a0..6fc8a2ce 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.ts @@ -201,15 +201,50 @@ export class OperationsTransformer { /** * Method that transforms operation against a plugin data operation * - * Plugin data operations only ever modify one key of one plugin's per-block data, so they - * cannot shift another operation's position. Two concurrent writes to the same key are left - * untransformed on purpose: the server's order decides, and the last applied one wins. + * 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 copy of the current operation + * @returns new transformed operation */ - #transformAgainstPluginDataOperation(operation: Operation, againstOp: Operation): Operation { - void againstOp; + #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; + + // Cover case 3 + if (againstRevision > operation.rev!) { + return new Operation(OperationType.Neutral, index, { payload: [] }, operation.userId, operation.rev); + } return Operation.from(operation); } From 7546fa266cf2bb817570195ab67013aaf255fe45 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sun, 11 Oct 2026 21:44:31 +0100 Subject: [PATCH 12/49] docs(openspec): check off the second-round plugin-block-data follow-ups Co-Authored-By: Claude Opus 5 --- openspec/changes/plugin-block-data/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/plugin-block-data/tasks.md b/openspec/changes/plugin-block-data/tasks.md index 32a95d08..8010372f 100644 --- a/openspec/changes/plugin-block-data/tasks.md +++ b/openspec/changes/plugin-block-data/tasks.md @@ -60,8 +60,8 @@ ## 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) From e47f7849d1b4cb98f2a3e36f25607905d74845f2 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 16 Oct 2026 23:39:29 +0100 Subject: [PATCH 13/49] fix(model,core): keep plugin data out of reach of Object.prototype Plugin names and the keys inside an entry are document data, and both could collide with the prototype. PluginDataNode kept its keys on a plain object while the block's own plugin store already used a null prototype. A `__proto__` key loaded from a document reached the prototype setter instead of becoming an own property and vanished from serialization; a first write to a key like `toString` read the inherited function as its previous value and put that on the undo stack and the wire. The record is now copied onto a null prototype, as the outer store is. `serialized` handed out the live record, so `getPluginData` and `api.document.data` both exposed an untracked write path into the Model. Read, change, write back -- the obvious way to use the two methods together -- changed the document on the read and then emitted nothing, because the no-op guard found the value already updated. Callers get a detached copy. BlocksManager.getPluginData indexed the serialized map directly, so a plugin named toString or constructor read back as the inherited function rather than undefined. It checks for an own entry first. Co-Authored-By: Claude Opus 5 --- .../src/api/BlocksAPI.integration.spec.ts | 63 +++++++++++++++ packages/core/src/components/BlockManager.ts | 12 ++- .../PluginDataNode/PluginDataNode.spec.ts | 79 +++++++++++++++++++ .../src/entities/PluginDataNode/index.ts | 29 ++++++- 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index 062cc556..a8bd5db4 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -1250,6 +1250,69 @@ describe('BlocksAPI integration (real model, mocked tools)', () => { 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' }); diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index e5e4c4b3..5d0fdaa7 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -443,8 +443,18 @@ export class BlocksManager { */ public getPluginData(blockIndexOrId: number | BlockId, pluginName: string): PluginDataSerialized | undefined { const blockIndex = this.#model.resolveBlockIndex(blockIndexOrId); + const plugins = this.#model.getBlockSerialized(blockIndex).plugins; - return this.#model.getBlockSerialized(blockIndex).plugins?.[pluginName]; + /** + * 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]; } /** diff --git a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts index 5e2fb930..237f42dd 100644 --- a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts +++ b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts @@ -207,7 +207,86 @@ describe('PluginDataNode', () => { }); }); + 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('.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({ diff --git a/packages/model/src/entities/PluginDataNode/index.ts b/packages/model/src/entities/PluginDataNode/index.ts index 1da9e1d0..c1279fe3 100644 --- a/packages/model/src/entities/PluginDataNode/index.ts +++ b/packages/model/src/entities/PluginDataNode/index.ts @@ -8,6 +8,18 @@ import { PluginDataModifiedEvent } from '@editorjs/model-types'; +/** + * 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 @@ -34,7 +46,15 @@ export class PluginDataNode extends EventBus { super(); this.#name = name; - this.#data = data; + + /** + * 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 + */ + this.#data = Object.assign(Object.create(null) as Record, data); } /** @@ -89,8 +109,13 @@ export class PluginDataNode extends EventBus { /** * 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 this.#data; + return detach(this.#data); } } From f8fa0cd888dad6e25a88f4d60ba9a9702abc3ce2 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 16 Oct 2026 23:55:48 +0100 Subject: [PATCH 14/49] docs(openspec): check off the third-round plugin-block-data follow-ups Co-Authored-By: Claude Opus 5 --- openspec/changes/plugin-block-data/tasks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openspec/changes/plugin-block-data/tasks.md b/openspec/changes/plugin-block-data/tasks.md index 8010372f..5a824728 100644 --- a/openspec/changes/plugin-block-data/tasks.md +++ b/openspec/changes/plugin-block-data/tasks.md @@ -65,6 +65,6 @@ ## 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` From b004126e3490ad7779ca9b47b2086670dac3f59c Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 17 Oct 2026 17:22:18 +0100 Subject: [PATCH 15/49] test(collaboration-manager): say what "not transformed" actually means The cases asserting that a plugin data operation is left alone checked only that its type was still Modify. That says it was not neutralised and nothing more: with the untransformed branch returning an operation whose block index was shifted by 99, all thirty cases still passed. Comparing the serialized form covers the index, both payloads, the user and the revision in one assertion. The two serialization cases were named after a metaphor rather than what they do, which is a JSON round-trip standing in for what reaches the OT server. Co-Authored-By: Claude Opus 5 --- .../src/Operation.spec.ts | 11 +++---- .../src/OperationsTransformer.spec.ts | 30 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/collaboration-manager/src/Operation.spec.ts b/packages/collaboration-manager/src/Operation.spec.ts index f3ca029d..c0633632 100644 --- a/packages/collaboration-manager/src/Operation.spec.ts +++ b/packages/collaboration-manager/src/Operation.spec.ts @@ -326,11 +326,12 @@ describe('Operation', () => { }); describe('.serialize()', () => { /** - * A key removal is a Modify whose payload is `undefined`, and `JSON.stringify` drops a key - * holding `undefined` entirely. It survives only because reading a key that is not there - * gives `undefined` back -- which is worth an assertion, since nothing in the types says so. + * 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 carry a plugin data removal through the wire', () => { + 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, @@ -346,7 +347,7 @@ describe('Operation', () => { expect((received.data as ModifyOperationData).prevPayload).toBe('intro'); }); - it('should carry the plugin data index itself through the wire', () => { + 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, diff --git a/packages/collaboration-manager/src/OperationsTransformer.spec.ts b/packages/collaboration-manager/src/OperationsTransformer.spec.ts index ced62bfe..71623af8 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.spec.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.spec.ts @@ -620,8 +620,7 @@ describe('OperationsTransformer', () => { const result = transformer.transform(operation, againstOp); - expect(result.type).toBe(OperationType.Modify); - expect((result.index as PluginDataIndex).blockIndex).toBe(2); + expect(result.serialize()).toEqual(operation.serialize()); }); it('should not be transformed against an unsent write to another key of the same plugin', () => { @@ -634,7 +633,14 @@ describe('OperationsTransformer', () => { 'user2' ); - expect(transformer.transform(incoming, unsentLocal).type).toBe(OperationType.Modify); + 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', () => { @@ -647,7 +653,14 @@ describe('OperationsTransformer', () => { 'user2' ); - expect(transformer.transform(incoming, unsentLocal).type).toBe(OperationType.Modify); + 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', () => { @@ -660,7 +673,14 @@ describe('OperationsTransformer', () => { 'user2' ); - expect(transformer.transform(incoming, unsentLocal).type).toBe(OperationType.Modify); + 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 transform a text operation against a plugin data operation without throwing', () => { From 92c0657a8ae4e52dafdcacc2f3f4e4bab35a75c7 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 5 Nov 2026 01:18:11 +0000 Subject: [PATCH 16/49] fix(model): copy plugin data values in and out of the store A plugin that wrote an object kept a reference into the Model: editing it moved the document with no write at all, and the write-back that should have announced the change found the value unchanged and emitted nothing -- no operation for collaborators, no step to undo. The data a node was constructed with was shared the same way, down from a loaded document. Incoming values are now cloned on the way in and for the event payload, since an operation and an undo step both outlive the call that recorded them. Because what is stored is therefore a copy, the no-op guard compares structurally; `deepEqual` moves into its own util for `isSameInlineData` to share, and reports a value whose state is not in its own keys -- a `Date`, a `Map` -- as different rather than as equal to any other of its kind. Co-Authored-By: Claude Opus 5 --- .../PluginDataNode/PluginDataNode.spec.ts | 102 ++++++++++++++++++ .../src/entities/PluginDataNode/index.ts | 34 +++++- packages/model/src/utils/deepEqual.spec.ts | 64 +++++++++++ packages/model/src/utils/deepEqual.ts | 53 +++++++++ packages/model/src/utils/index.ts | 1 + packages/model/src/utils/isSameInlineData.ts | 37 +------ 6 files changed, 250 insertions(+), 41 deletions(-) create mode 100644 packages/model/src/utils/deepEqual.spec.ts create mode 100644 packages/model/src/utils/deepEqual.ts diff --git a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts index 237f42dd..f85eecdd 100644 --- a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts +++ b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts @@ -250,6 +250,108 @@ describe('PluginDataNode', () => { }); }); + 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, diff --git a/packages/model/src/entities/PluginDataNode/index.ts b/packages/model/src/entities/PluginDataNode/index.ts index c1279fe3..5e4e33d4 100644 --- a/packages/model/src/entities/PluginDataNode/index.ts +++ b/packages/model/src/entities/PluginDataNode/index.ts @@ -1,4 +1,5 @@ import { getContext } from '../../utils/Context.js'; +import { deepEqual } from '../../utils/deepEqual.js'; import type { PluginDataNodeConstructorParameters } from './types/index.js'; import { PartialIndex, @@ -8,6 +9,15 @@ import { 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. * @@ -52,9 +62,12 @@ export class PluginDataNode extends EventBus { * 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 + * 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 = Object.assign(Object.create(null) as Record, data); + this.#data = detach(data); } /** @@ -85,23 +98,34 @@ export class PluginDataNode extends EventBus { * 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 (Object.is(previousValue, value) && (value !== undefined || !(key in this.#data))) { + if (deepEqual(previousValue, value) && (value !== undefined || !(key in this.#data))) { return; } if (value === undefined) { delete this.#data[key]; } else { - this.#data[key] = value; + /** + * 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 }), { - value, + /** + * 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()!) ); diff --git a/packages/model/src/utils/deepEqual.spec.ts b/packages/model/src/utils/deepEqual.spec.ts new file mode 100644 index 00000000..fd617b67 --- /dev/null +++ b/packages/model/src/utils/deepEqual.spec.ts @@ -0,0 +1,64 @@ +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 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. From 6121800926704c1e6631058ebe8e7285bd889e22 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 5 Nov 2026 01:18:19 +0000 Subject: [PATCH 17/49] fix(collaboration-manager): drop undo entries a collaborator overwrote `againstRevision > operation.rev!` was false whenever the left side came off an undo or redo stack, because those entries are built from model events and never carry a revision. The entry survived, so undoing a local first write to a key a collaborator had since overwritten wrote `prevPayload` over their value and deleted it. An operation with no revision of its own now loses to whatever it is transformed against, which in that path is only ever an operation the model has already applied. Server operations always carry a revision, so nothing else reaches the comparison without one. `#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. Co-Authored-By: Claude Opus 5 --- .../src/OperationsTransformer.spec.ts | 49 +++++++++++++++++ .../src/OperationsTransformer.ts | 8 ++- .../src/UndoRedoManager.spec.ts | 52 ++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/collaboration-manager/src/OperationsTransformer.spec.ts b/packages/collaboration-manager/src/OperationsTransformer.spec.ts index 71623af8..adde8df4 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.spec.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.spec.ts @@ -683,6 +683,55 @@ describe('OperationsTransformer', () => { 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, diff --git a/packages/collaboration-manager/src/OperationsTransformer.ts b/packages/collaboration-manager/src/OperationsTransformer.ts index 6fc8a2ce..bbc399b1 100644 --- a/packages/collaboration-manager/src/OperationsTransformer.ts +++ b/packages/collaboration-manager/src/OperationsTransformer.ts @@ -241,8 +241,14 @@ export class OperationsTransformer { */ 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 > operation.rev!) { + if (againstRevision > revision) { return new Operation(OperationType.Neutral, index, { payload: [] }, operation.userId, operation.rev); } 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); + }); }); }); From 384329c20b899aaaa8d9dc4577173438b4dbc228 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 5 Nov 2026 01:18:25 +0000 Subject: [PATCH 18/49] docs(openspec): specify and check off the fourth-round plugin-block-data follow-ups The spec change for the stack is merged, so this round's delta specs land here, with the code that implements them, rather than in a spec PR of their own. Co-Authored-By: Claude Opus 5 --- .../specs/collaboration-manager/spec.md | 12 +++++++++- .../specs/plugin-block-data/spec.md | 22 +++++++++++++++++++ openspec/changes/plugin-block-data/tasks.md | 6 +++++ 3 files changed, 39 insertions(+), 1 deletion(-) 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..12c7367a 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, 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. 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` @@ -38,6 +38,16 @@ - **WHEN** B receives A's operation while its own write is still unsent - **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: 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 5a824728..016adbeb 100644 --- a/openspec/changes/plugin-block-data/tasks.md +++ b/openspec/changes/plugin-block-data/tasks.md @@ -68,3 +68,9 @@ - [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 From 9b749a4361fabf38a9dcf0e547563756258391a8 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 5 Nov 2026 02:00:56 +0000 Subject: [PATCH 19/49] test(model): kill the surviving mutants in the plugin data copy paths Scoped mutation runs over the files this round touched left six survivors. Four are now dead: - The array-vs-record kind check in `deepEqual` was only tested with `[]` against `{}`, which returns `false` either way. An array and an array-like record agree on `length` and on every index, so `{ length: 0 }` is the case that tells the branch apart. - Array lengths were only compared for arrays that also differed in content, so a matching prefix of a longer array went untested. - The `||` in the kind guard short-circuits the same way as `&&` when neither side is keyed, which is all the `Date`-against-`Date` cases did. Mixed sides are what it guards. - Removing a key that is present and explicitly holds `undefined` -- which only a loaded document can produce -- is a real change that no test covered, though the comment beside the no-op guard has described it since the write-that-changes-nothing round. The four left on `copyValue`'s guard all amount to cloning when nothing needs it, which is observable only for a value that cannot be cloned at all. Tightening that is a contract change rather than a test gap, so it stays as it is. `model` scores 94% over these files against a threshold of 75%, with `deepEqual.ts` and `isSameInlineData.ts` at 100%. Co-Authored-By: Claude Opus 5 --- .../PluginDataNode/PluginDataNode.spec.ts | 19 +++++++++++++++ packages/model/src/utils/deepEqual.spec.ts | 23 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts index f85eecdd..e31e9e93 100644 --- a/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts +++ b/packages/model/src/entities/PluginDataNode/PluginDataNode.spec.ts @@ -149,6 +149,25 @@ describe('PluginDataNode', () => { 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, diff --git a/packages/model/src/utils/deepEqual.spec.ts b/packages/model/src/utils/deepEqual.spec.ts index fd617b67..f89c4c0a 100644 --- a/packages/model/src/utils/deepEqual.spec.ts +++ b/packages/model/src/utils/deepEqual.spec.ts @@ -29,6 +29,29 @@ describe('deepEqual', () => { 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 }); From a268f2c8ed156cdd98fec4fa61235ac9389f640f Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sun, 8 Nov 2026 13:43:42 +0000 Subject: [PATCH 20/49] fix(collaboration-manager): keep a sent operation pending until it is acknowledged `#sendNextOperation` shifted an operation off `#pendingOperations` in order to send it, and only pushed it to `#resolvedOperations` -- which nothing reads -- once the server acknowledged it. For the whole round trip the operation was in neither place, and `#onMessage` transforms incoming operations against `#pendingOperations` alone: a collaborator's write to the same value arriving in that window was transformed against nothing, applied over the local one, and never corrected, since a client's own operations are not echoed back to it. The convergence rule added earlier covered only the window before an operation is sent, which is a microtask, while this one is a full round trip -- so it is the window a simultaneous pair of `updatePluginData` calls actually lands in. `openspec/specs/collaboration-manager/spec.md` already required this behaviour; the code did not conform. Safety rests on the server ordering its messages: while an acknowledgement is outstanding, every remote operation delivered was ordered before the local one, so neutralising it is correct, and once it arrives, everything after it was ordered later and applies normally. Reading the head without removing it meant two concurrent `send` calls would both read the same operation and send it twice, so `#awaitingAcknowledgement` is now claimed before the first await. The guard on the acknowledgement handler also carries more weight than it did: mistaking a remote operation for the acknowledgement would now drop a still-in-flight operation from the pending list, reopening the window this closes, so it has a test of its own. The tests also close a hole they uncovered: nothing checked that sending ever resumes after an acknowledgement, only that a queued operation is held back while one is in flight -- so the acknowledgement gate could have latched shut permanently with the suite still green. Co-Authored-By: Claude Opus 5 --- .../src/client/OTClient.spec.ts | 195 +++++++++++++++++- .../src/client/OTClient.ts | 24 ++- 2 files changed, 212 insertions(+), 7 deletions(-) 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); From 99dc155a8c10f4b97b750b6f502f8def2ab55481 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sun, 8 Nov 2026 13:43:42 +0000 Subject: [PATCH 21/49] docs(openspec): specify and check off the fifth-round plugin-block-data follow-ups The convergence rule said "unsent" where it meant "unacknowledged", which is the distinction the round turned on. Co-Authored-By: Claude Opus 5 --- .../specs/collaboration-manager/spec.md | 14 ++++++++++++-- openspec/changes/plugin-block-data/tasks.md | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) 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 12c7367a..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. 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. +`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,19 @@ #### 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 diff --git a/openspec/changes/plugin-block-data/tasks.md b/openspec/changes/plugin-block-data/tasks.md index 016adbeb..f13451db 100644 --- a/openspec/changes/plugin-block-data/tasks.md +++ b/openspec/changes/plugin-block-data/tasks.md @@ -74,3 +74,8 @@ - [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 From 7e001acfd7775ee32017f297b9471e33f0dd4528 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 01:21:54 +0100 Subject: [PATCH 22/49] refactor!: remove the Block Tune tool kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registered tune got a `BlockTuneFacade` built and sorted into `ToolsManager.blockTunes`, and nothing ever read it — no adapter, no rendering, no model wiring. It was a second, half-built way to do what a plugin now does, so it goes rather than stays as an alternative to document around. Removed from `sdk`: `ToolType.Tune`, the `BlockTune*` contracts and `BlockTuneFacade`, `BaseToolFacade#isTune()`, `ToolsCollection#blockTunes`, `ToolTypeToOptions[ToolType.Tune]`, and the tune members of the `ToolConstructable`/`ToolFacadeClass` unions. The per-tool allow-list goes too — `BlockToolOptionKey.Tunes`, `UserToolOptions.EnabledBlockTunes`, `BlockToolFacade#enabledBlockTunes` and `BlockToolFacade#tunes` — because per-tool filtering belongs to the providers, which get `tool` in their context. If integrators want the allow-list back it returns through `options.plugins['block-settings']`, not as a member of the tool contract. Removed from `core`: the Tune branch of `use()`, the `getAll(ToolType.Tune)` preparation step, the Tune case in `ToolsFactory` and `ToolsManager#blockTunes`. `shortcuts-plugin` loses its never-called `#processBlockTune`. The removal is guarded by compile-time assertions rather than runtime ones: under `ts-jest` a stale `@ts-expect-error` is itself a diagnostic, so each directive fails the suite while the name it points at exists. `BlockToolOptions['tunes']` is the one member that cannot be asserted — the interface carries an open index signature for tool-author options, so every key resolves. BREAKING CHANGE: registering a tune through `core.use()` or setting a tool's `tunes` option no longer compiles. Write a plugin instead: it puts an item in the block settings menu and stores state as per-block plugin data. Co-Authored-By: Claude Opus 5 --- packages/core/src/index.spec.ts | 54 ++++++++++++- packages/core/src/index.ts | 6 +- packages/core/src/tools/ToolsFactory.ts | 8 +- packages/core/src/tools/ToolsManager.ts | 10 +-- .../plugins/shortcuts-plugin/src/index.ts | 11 --- packages/sdk/src/api/BlocksAPI.ts | 3 +- packages/sdk/src/entities/BaseTool.ts | 9 +-- packages/sdk/src/entities/BlockTool.ts | 8 -- packages/sdk/src/entities/BlockTune.ts | 79 ------------------ packages/sdk/src/entities/EntityType.ts | 7 +- packages/sdk/src/entities/index.ts | 1 - .../sdk/src/tools/ToolsCollection.spec.ts | 63 +++++++++++++++ packages/sdk/src/tools/ToolsCollection.ts | 12 --- .../src/tools/facades/BaseToolFacade.spec.ts | 80 ++++++++++++++++++- .../sdk/src/tools/facades/BaseToolFacade.ts | 20 ++--- .../sdk/src/tools/facades/BlockToolFacade.ts | 15 +--- .../sdk/src/tools/facades/BlockTuneFacade.ts | 36 --------- packages/sdk/src/tools/facades/index.ts | 4 +- 18 files changed, 211 insertions(+), 215 deletions(-) delete mode 100644 packages/sdk/src/entities/BlockTune.ts create mode 100644 packages/sdk/src/tools/ToolsCollection.spec.ts delete mode 100644 packages/sdk/src/tools/facades/BlockTuneFacade.ts 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.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/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 6e1e1281..896477bd 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -285,9 +285,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..09aaf2ca 100644 --- a/packages/sdk/src/entities/EntityType.ts +++ b/packages/sdk/src/entities/EntityType.ts @@ -40,12 +40,7 @@ export enum ToolType { /** * Inline Tool */ - Inline = 'inline', - - /** - * Block Tune - */ - Tune = 'tune' + Inline = 'inline' } /** 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/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'; From 111c671405889e0d07ecbe23e4227f94e7edecb4 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 01:22:02 +0100 Subject: [PATCH 23/49] docs: drop the Block Tune tool kind `docs/plugins.md` loses the registration-table row and gains a line pointing a would-be tune author at the block settings menu and per-block plugin data instead; `docs/README.md` and the plugin-lifecycle diagram drop their `BlockTune` / `ToolType.Tune` / `blockTunes` mentions. Two live specs are hand-edited because the mechanical archive fold only rewrites `### Requirement:` blocks: the `sdk` **Purpose** preamble, and the `shortcuts-plugin` reserved-work note, which now reserves shortcuts for block settings items rather than for block tunes. Co-Authored-By: Claude Opus 5 --- docs/README.md | 2 +- docs/diagrams/plugin-lifecycle-flow.mmd | 6 ++--- docs/plugins.md | 6 +++-- openspec/changes/remove-block-tunes/tasks.md | 26 ++++++++++---------- 4 files changed, 21 insertions(+), 19 deletions(-) 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/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/plugins.md b/docs/plugins.md index 30722b3b..cce3fb35 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,9 @@ 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 and stores its state as [per-block plugin data](#per-block-plugin-data). ## Initialization sequence diff --git a/openspec/changes/remove-block-tunes/tasks.md b/openspec/changes/remove-block-tunes/tasks.md index b79330e8..f03b295c 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 From 56a3f4ecd4086f8a75d43b46cbc49893d24b4605 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 01:27:13 +0100 Subject: [PATCH 24/49] test(core): cover the tool routing the tune case was removed from `ToolsFactory#getConstructor` had no coverage at all, so the switch this branch removed the Tune case from was unguarded: a mutant collapsing the remaining Inline case survived. The new spec pins both remaining kinds to their facades, along with the `defaultBlock` flag, the options handed to the facade, and the unregistered-tool error. `ToolsFactory.ts` now scores 100 on mutation testing, up from 40. Co-Authored-By: Claude Opus 5 --- packages/core/src/tools/ToolsFactory.spec.ts | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/core/src/tools/ToolsFactory.spec.ts 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'); + }); + }); +}); From f484edd9f4a52ab79333bf5ac8d5a691d2bffe2a Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 26 Sep 2026 01:27:51 +0100 Subject: [PATCH 25/49] docs(openspec): check off the implemented remove-block-tunes tasks Task 4.5 stays open: closing PR #157 with links to its three replacement changes is a public comment, so it waits for the maintainer. Co-Authored-By: Claude Opus 5 --- openspec/changes/remove-block-tunes/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/remove-block-tunes/tasks.md b/openspec/changes/remove-block-tunes/tasks.md index f03b295c..91b43ceb 100644 --- a/openspec/changes/remove-block-tunes/tasks.md +++ b/openspec/changes/remove-block-tunes/tasks.md @@ -23,4 +23,4 @@ - [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 From e2760fac79680cc6c3df31d9bbedb83ada336c8f Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 10 Oct 2026 01:00:42 +0100 Subject: [PATCH 26/49] docs(sdk): stop MenuConfig describing a contract that no longer exists `MenuConfig` still advertised itself as the shape for "Block Tune's render()", the last reference in shipped source to the tool kind this change removes. Co-Authored-By: Claude Opus 5 --- packages/sdk/src/entities/MenuConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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[]; From f5e0ccbbbedf585203d7a8e5c06093bcb8c5f1ce Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 01:11:47 +0100 Subject: [PATCH 27/49] test(ui): give the package a Jest harness `@editorjs/ui` is the first package here that renders DOM, so it is also the first to need `jest-environment-jsdom` as an explicit dependency. Two module mappings make its sources loadable under Jest: `@codexteam/ui`'s side-effect theme imports become an empty module, and CSS Modules resolve to a proxy that answers each property with its own name, so an assertion on rendered markup names the class the source asked for. `@codexteam/*` joins `@editorjs/*` in `transformIgnorePatterns` because ui-kit ships as ESM. Declaration emit moves to `tsconfig.build.json`, the config that excludes `*.spec.ts`, so `dist` does not grow typings for the tests. The shared `base-coverage` action skipped a package missing from the base ref but not one present there without a test suite -- the case of any PR adding a first suite, this one included. It would have run `test:coverage` on the base ref and failed with "couldn't find a script", so its guard now also checks that the script exists. Co-Authored-By: Claude Opus 5 --- .github/actions/base-coverage/action.yml | 20 ++- .github/workflows/ui.yml | 10 ++ openspec/changes/block-settings-ui/tasks.md | 13 +- packages/ui/eslint.config.mjs | 12 ++ packages/ui/jest.config.ts | 30 ++++- packages/ui/package.json | 4 + packages/ui/vite.config.ts | 3 + yarn.lock | 141 ++++++++++---------- 8 files changed, 153 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/ui.yml 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/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/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index eab6903a..3903a363 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -15,12 +15,13 @@ ## 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 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..985ba2e4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,14 +26,18 @@ "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/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..3f5a408b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2689,6 +2689,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 +2702,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 +4076,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 +4124,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 +12419,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 +12472,7 @@ __metadata: optional: true bin: rolldown: ./bin/cli.mjs - checksum: 7e602afd9c851e1e4c26711030211ec36c538f57a59e5c84ea74167a98db14eeecc8741a8c3564bcce8b9377ec0c5502973acc823bca4788da25ad6f70283416 + checksum: 8573c260516eefed8266da2dced4fa0742c8c44dbc79d35f5cc43cf1cfdaa1afedca7ab31e1abcf733be598f1866d5f8047884826717f37a507e70b4a0e510a2 languageName: node linkType: hard From 97ec4f277df1428feba1eb6ff89351a517ee07f8 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 01:20:06 +0100 Subject: [PATCH 28/49] feat(ui): add the block settings menu plugin `BlockSettingsUI` renders the per-block settings popover and owns no items of its own: everything in the menu comes from providers registered through `api.plugins['block-settings'].register`, which are asked again on every open so items reflect current state. Contributions are concatenated by ascending `order` (ties in registration order) with a separator between providers, and a provider returning nothing contributes no separator either. Providers receive `blockId` as well as `blockIndex`, because the index is only true at the moment the menu was built -- an item's handler is expected to resolve the block's position from the id instead. Requesting settings for an index that resolves to no block invokes no provider and opens nothing. The menu is rebuilt rather than edited between opens. `removeItemByName` is the only removal ui-kit offers and a separator carries no name, so a fresh popover with the full item list is what keeps the previous block's items out of the accessibility tree. The swap happens inside the plugin's own holder element, so ui-kit's rendered DOM stays untouched. The event classes and the popover setup are ported from the BlockTunes work in PR #157. Co-authored-by: Betty Steger <244475+bettysteger@users.noreply.github.com> Co-Authored-By: Claude Opus 5 --- openspec/changes/block-settings-ui/tasks.md | 15 +- packages/sdk/src/entities/EntityType.ts | 7 +- .../src/BlockSettings/BlockSettings.spec.ts | 440 ++++++++++++++++++ .../ui/src/BlockSettings/BlockSettings.ts | 347 ++++++++++++++ .../events/BlockSettingsBaseEvent.ts | 15 + .../events/BlockSettingsClosedUIEvent.ts | 20 + .../events/BlockSettingsOpenUIEvent.ts | 28 ++ .../events/BlockSettingsOpenedUIEvent.ts | 25 + .../events/BlockSettingsRenderedUIEvent.ts | 25 + packages/ui/src/BlockSettings/events/index.ts | 5 + packages/ui/src/index.ts | 1 + packages/ui/src/messages.ts | 9 + 12 files changed, 929 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/BlockSettings/BlockSettings.spec.ts create mode 100644 packages/ui/src/BlockSettings/BlockSettings.ts create mode 100644 packages/ui/src/BlockSettings/events/BlockSettingsBaseEvent.ts create mode 100644 packages/ui/src/BlockSettings/events/BlockSettingsClosedUIEvent.ts create mode 100644 packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts create mode 100644 packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts create mode 100644 packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts create mode 100644 packages/ui/src/BlockSettings/events/index.ts diff --git a/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index 3903a363..73388e44 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -25,8 +25,8 @@ ## 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 @@ -35,11 +35,12 @@ - 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 diff --git a/packages/sdk/src/entities/EntityType.ts b/packages/sdk/src/entities/EntityType.ts index 09aaf2ca..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' } /** diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts new file mode 100644 index 00000000..eaf0f58e --- /dev/null +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -0,0 +1,440 @@ +import { 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 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; + +/** + * 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; + /** 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)), + }, + document: { + /** + * Read fresh each time, so a test can mutate the block list between opens + */ + get data() { + return { blocks }; + }, + }, + plugins: {}, + } as unknown as EditorAPI; + + let element: HTMLElement | 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 { + element = (event.detail as BlockSettingsRenderedUIEventPayload).blockSettings; + } + + 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) { + throw new Error('BlockSettingsUI did not announce its element'); + } + + holder.appendChild(element); + + return { + plugin, + eventBus, + element, + 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; +} + +/** + * 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); + expect((onOpened.mock.calls[0][0] as CustomEvent).detail).toEqual({ blockId: 'block-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('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..4e4f120b --- /dev/null +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -0,0 +1,347 @@ +import { make } from '@editorjs/dom'; +import type { + BlockId, + CoreConfigValidated, + EditorAPI, + EditorjsPlugin, + EditorjsPluginParams, + EventBus +} from '@editorjs/sdk'; +import { UiComponentType } from '@editorjs/sdk'; +import type { PopoverItemParams } from '@editorjs/ui-kit'; +import { PopoverDesktop, PopoverEvent, PopoverItemType } from '@editorjs/ui-kit'; +import { messages } from '../messages.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: + * 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 MenuConfig = 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 +) => MenuConfig | undefined | Promise; + +/** + * Options a provider is registered with + */ +export interface BlockSettingsRegisterOptions { + /** + * Ascending sort key for this provider's contribution. Defaults to `0`, ties are 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 */ +} + +/** + * 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; +} + +/** + * UI plugin that renders the per-block settings menu. + * + * Mirrors {@link ToolboxUI}: it announces its element once so `ToolbarUI` can mount it, then + * builds and opens the menu whenever it receives `ui:block-settings:open`. 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 `ToolbarUI`. The popover inside it is replaced per open, + * so this is what stays mounted + */ + #holder: HTMLElement; + + /** + * Popover rendering the current menu, absent until the menu is first built + */ + #popover: PopoverDesktop | undefined; + + /** + * Registered providers + */ + readonly #registrations = new Set(); + + /** + * Incremented per registration so providers sharing an `order` keep their registration sequence + */ + #sequence = 0; + + /** + * 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.#eventBus.dispatchEvent(new BlockSettingsRenderedUIEvent({ + blockSettings: this.#holder, + })); + + this.#eventBus.addEventListener('ui:block-settings:open', (event: BlockSettingsOpenUIEvent) => { + void this.#open(event.detail.index); + }); + } + + /** + * Cleanup when plugin is destroyed + */ + public destroy(): void { + this.#popover?.destroy(); + this.#popover = undefined; + this.#holder.remove(); + } + + /** + * 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()` emits the popover's own closed event, + * which is what dispatches `ui:block-settings:closed` + */ + #close(): void { + if (this.#popover?.isShown === true) { + this.#popover.hide(); + } + } + + /** + * 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 blockId = this.#api.blocks.getIdByIndex(index); + + /** + * Nothing is selected, or the index is stale. Providers are not asked about a block that + * is not there, and an opening request for one is simply dropped + */ + if (blockId === undefined) { + return; + } + + const items = await this.#collectItems({ + blockId, + blockIndex: index, + tool: this.#api.document.data.blocks[index]?.name ?? '', + }); + + if (items.length === 0) { + return; + } + + this.#renderPopover(items); + + this.#popover?.show(); + + this.#eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({ blockId })); + } + + /** + * 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 }) => { + const result = await provider(context); + + if (result === undefined) { + return []; + } + + return Array.isArray(result) ? result : [result]; + }) + ); + + return contributions + .filter(contribution => contribution.length > 0) + .reduce((items, contribution) => { + if (items.length > 0) { + items.push({ type: PopoverItemType.Separator }); + } + + return items.concat(contribution); + }, []); + } + + /** + * Replaces the popover inside the holder with one built for the given items. + * + * The menu is rebuilt rather than edited in place: `removeItemByName` is the only removal + * `@editorjs/ui-kit` offers, and a separator is constructed without params, so it has no name + * to be removed by. Handing a fresh popover the complete item list is what guarantees that + * the items of the previous block leave the accessibility tree. + * @param items - items the menu should consist of + */ + #renderPopover(items: PopoverItemParams[]): void { + this.#popover?.destroy(); + + 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, + }, + }, + { + [PopoverItemType.Default]: { wrapperTag: 'button' }, + } + ); + + this.#popover.on(PopoverEvent.Closed, () => { + this.#eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); + }); + + 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..108565e9 --- /dev/null +++ b/packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts @@ -0,0 +1,28 @@ +import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; + +/** + * Names the block whose settings should be shown + */ +export interface BlockSettingsOpenUIEventPayload { + /** + * Position of the block to show settings for. + * + * An index rather than an id because the dispatcher is the toolbar, which follows block + * selection by position. `BlockSettingsUI` resolves it to an id once, when 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..d90a27ab --- /dev/null +++ b/packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts @@ -0,0 +1,25 @@ +import type { BlockId } from '@editorjs/sdk'; +import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; + +/** + * Reports which block the menu that just opened was built for + */ +export interface BlockSettingsOpenedUIEventPayload { + /** + * Id of the block the opened menu was built for + */ + readonly blockId: BlockId; +} + +/** + * 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..4bd4bd3c --- /dev/null +++ b/packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts @@ -0,0 +1,25 @@ +import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; + +/** + * Payload of the BlockSettingsRenderedUIEvent + * Contains the block settings HTML element + */ +export interface BlockSettingsRenderedUIEventPayload { + /** + * Block settings HTML element + */ + 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/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', From ff2a69eaf2dba2299d492237ea8e3fa345937c7d Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 01:24:23 +0100 Subject: [PATCH 29/49] feat(ui): open block settings from the toolbar Adds the settings button beside the plus button, appended before the roving tabindex is initialized so the actions container keeps exactly one tab stop. It follows the same menu-button contract as the plus button: an accessible name from the message catalogue, `aria-haspopup="menu"`, an `aria-expanded` kept in sync with the menu's own events, and focus taken on click so the popover has somewhere to return it on close. The toolbar now also tracks the hovered block's index, not just its element, and holds position while the menu is open -- the menu was built for one block, so moving off it mid-interaction would leave the two pointing at different blocks. Fixes `ToolboxOpenUIEvent` being constructed with an event name instead of a payload. The empty payload interface accepted the string, so listeners had been receiving `detail: 'ui:toolbox:open'`; the event's name comes from the class and was never affected. Ported from PR #157. Co-authored-by: Betty Steger <244475+bettysteger@users.noreply.github.com> Co-Authored-By: Claude Opus 5 --- openspec/changes/block-settings-ui/tasks.md | 11 +- packages/ui/src/Toolbar/Toolbar.const.ts | 1 + packages/ui/src/Toolbar/Toolbar.module.pcss | 8 + packages/ui/src/Toolbar/Toolbar.spec.ts | 207 ++++++++++++++++++++ packages/ui/src/Toolbar/Toolbar.ts | 97 ++++++++- 5 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/Toolbar/Toolbar.spec.ts diff --git a/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index 73388e44..08924400 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -44,10 +44,11 @@ ## 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 @@ -55,7 +56,7 @@ - `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 diff --git a/packages/ui/src/Toolbar/Toolbar.const.ts b/packages/ui/src/Toolbar/Toolbar.const.ts index 02a9b221..658273ec 100644 --- a/packages/ui/src/Toolbar/Toolbar.const.ts +++ b/packages/ui/src/Toolbar/Toolbar.const.ts @@ -6,4 +6,5 @@ export const css = { toolbar: className(), actions: className('actions'), plusButton: className('plus-button'), + settingsButton: className('settings-button'), }; diff --git a/packages/ui/src/Toolbar/Toolbar.module.pcss b/packages/ui/src/Toolbar/Toolbar.module.pcss index 07c97157..0cab49f4 100644 --- a/packages/ui/src/Toolbar/Toolbar.module.pcss +++ b/packages/ui/src/Toolbar/Toolbar.module.pcss @@ -42,6 +42,8 @@ &__actions { position: absolute; right: 100%; + display: flex; + align-items: center; } &__plus-button { @@ -49,4 +51,10 @@ flex-shrink: 0; } + + &__settings-button { + @apply --toolbar-button; + + flex-shrink: 0; + } } diff --git a/packages/ui/src/Toolbar/Toolbar.spec.ts b/packages/ui/src/Toolbar/Toolbar.spec.ts new file mode 100644 index 00000000..20331ef0 --- /dev/null +++ b/packages/ui/src/Toolbar/Toolbar.spec.ts @@ -0,0 +1,207 @@ +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')]; +} + +/** + * The button that opens block settings + * @param holder - the toolbar element + */ +function settingsButton(holder: HTMLElement): HTMLButtonElement { + const button = actionButtons(holder).find( + candidate => candidate.getAttribute('aria-label') === messages.blockSettingsButton + ); + + if (button === undefined) { + throw new Error('the toolbar rendered no settings button'); + } + + return button; +} + +/** + * 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('settings button', () => { + it('should request settings for the block the toolbar is following', () => { + const onOpen = jest.fn(); + + instance.eventBus.addEventListener('ui:block-settings:open', onOpen); + + selectBlock(instance.eventBus, 2); + settingsButton(instance.holder).click(); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect((onOpen.mock.calls[0][0] as CustomEvent).detail).toEqual({ index: 2 }); + }); + + it('should carry an accessible name and advertise the menu it controls', () => { + const button = settingsButton(instance.holder); + + expect(button.getAttribute('aria-label')).toBe(messages.blockSettingsButton); + expect(button.getAttribute('aria-haspopup')).toBe('menu'); + expect(button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should follow the menu state with aria-expanded', () => { + const button = settingsButton(instance.holder); + + instance.eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({ blockId: 'block-2' as never })); + expect(button.getAttribute('aria-expanded')).toBe('true'); + + instance.eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); + expect(button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should hold focus before the menu is asked to open', () => { + const button = settingsButton(instance.holder); + let focusedAtDispatch: Element | null = null; + + instance.eventBus.addEventListener('ui:block-settings:open', () => { + focusedAtDispatch = document.activeElement; + }); + + selectBlock(instance.eventBus, 0); + button.click(); + + expect(focusedAtDispatch).toBe(button); + }); + }); + + describe('mounting the menu', () => { + it('should mount the announced popover element into its actions area', () => { + const popover = document.createElement('div'); + + popover.dataset.testid = 'block-settings-popover'; + + instance.eventBus.dispatchEvent(new BlockSettingsRenderedUIEvent({ blockSettings: popover })); + + expect(instance.holder.querySelector('[role="toolbar"] [data-testid="block-settings-popover"]')).toBe(popover); + }); + }); + + describe('repositioning', () => { + it('should not move while block settings are open', () => { + const moveTo = jest.spyOn(instance.plugin, 'moveTo'); + + instance.eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({ blockId: 'block-0' as never })); + selectBlock(instance.eventBus, 1); + + expect(moveTo).not.toHaveBeenCalled(); + + instance.eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); + selectBlock(instance.eventBus, 1); + + expect(moveTo).toHaveBeenCalledTimes(1); + }); + }); + + describe('roving tabindex', () => { + it('should keep exactly one action button in the tab order', () => { + 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 move the tab stop with the arrow keys', () => { + const buttons = actionButtons(instance.holder); + + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + })); + + expect(buttons[1].tabIndex).toBe(0); + expect(buttons[0].tabIndex).toBe(-1); + }); + }); +}); diff --git a/packages/ui/src/Toolbar/Toolbar.ts b/packages/ui/src/Toolbar/Toolbar.ts index af005037..e29f1d1b 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -3,11 +3,13 @@ import { UiComponentType } from '@editorjs/sdk'; import { make } from '@editorjs/dom'; import { css } from './Toolbar.const.js'; import type { ToolboxRenderedUIEvent } from '../Toolbox/events/index.js'; -import { IconPlus } from '@codexteam/icons'; +import { IconMenuSmall, IconPlus } from '@codexteam/icons'; import Style from './Toolbar.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 { BlockSettingsOpenUIEvent } from '../BlockSettings/events/index.js'; import { messages } from '../messages.js'; /** @@ -38,6 +40,11 @@ interface ToolbarNodes { * Plus button to open Toolbox popover */ plusButton: HTMLButtonElement; + + /** + * Settings button to open the Block Settings popover + */ + settingsButton: HTMLButtonElement; } /** @@ -70,6 +77,9 @@ export class ToolbarUI implements EditorjsPlugin { plusButton: make('button', Style[css.plusButton], { innerHTML: IconPlus, }) as HTMLButtonElement, + settingsButton: make('button', Style[css.settingsButton], { + innerHTML: IconMenuSmall, + }) as HTMLButtonElement, }; /** @@ -77,6 +87,20 @@ 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; + + /** + * Position of the block the toolbar is currently following. + * + * `-1` until a block is reported, which is what the settings request carries in that case: + * it resolves to no block, and `BlockSettingsUI` drops the request + */ + #selectedBlockIndex = -1; + /** * Constructor function * @param args - plugin parameters @@ -96,11 +120,15 @@ 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; } + this.#selectedBlockIndex = event.detail.index; + this.moveTo(event.detail.block); }, { signal }); } @@ -137,6 +165,14 @@ export class ToolbarUI implements EditorjsPlugin { this.#nodes.actions.appendChild(toolboxElement); } + /** + * Adds the Block Settings popover to the editor UI + * @param blockSettingsElement - block settings HTML element to add to the page + */ + #addBlockSettings(blockSettingsElement: HTMLElement): void { + this.#nodes.actions.appendChild(blockSettingsElement); + } + /** * Creates Toolbar HTML nodes */ @@ -146,6 +182,12 @@ export class ToolbarUI implements EditorjsPlugin { this.#nodes.holder.appendChild(this.#nodes.actions); this.#nodes.actions.appendChild(this.#nodes.plusButton); + /** + * Appended before the roving tabindex is initialized below, so that both buttons are + * already in the container when the single tab stop is assigned + */ + this.#nodes.actions.appendChild(this.#nodes.settingsButton); + /** * The actions container groups related controls. It is named because the inline toolbar * is a toolbar too, and the plus button is icon-only, so it needs a name of its own @@ -154,6 +196,7 @@ export class ToolbarUI implements EditorjsPlugin { this.#nodes.actions.setAttribute('aria-label', messages.blockActionsToolbar); this.#nodes.actions.setAttribute('aria-orientation', 'horizontal'); this.#nodes.plusButton.setAttribute('aria-label', messages.addBlockButton); + this.#nodes.settingsButton.setAttribute('aria-label', messages.blockSettingsButton); /** * role="toolbar" is a promise that the group is a single tab stop navigated with the arrow @@ -172,6 +215,23 @@ export class ToolbarUI implements EditorjsPlugin { this.#nodes.plusButton.setAttribute('aria-haspopup', 'menu'); this.#nodes.plusButton.setAttribute('aria-expanded', 'false'); + /** + * Same menu-button contract as the plus button: it owns the settings menu and reports + * whether that menu is currently open + */ + this.#nodes.settingsButton.setAttribute('aria-haspopup', 'menu'); + this.#nodes.settingsButton.setAttribute('aria-expanded', 'false'); + + this.#nodes.settingsButton.addEventListener('click', () => { + /** + * Focused for the same reason the plus button is: Safari leaves a clicked button + * unfocused, and the popover would then restore focus to `document.body` on close + */ + this.#nodes.settingsButton.focus(); + + this.#openBlockSettings(); + }); + this.#nodes.plusButton.addEventListener('click', () => { /** * Safari does not focus a button when it is clicked, so without this the toolbox @@ -215,7 +275,38 @@ 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 { + this.#eventBus.addEventListener(`ui:block-settings:rendered`, (event: BlockSettingsRenderedUIEvent) => { + this.#addBlockSettings(event.detail.blockSettings); + }); + + this.#eventBus.addEventListener(`ui:block-settings:opened`, () => { + this.#isBlockSettingsOpen = true; + this.#nodes.settingsButton.setAttribute('aria-expanded', 'true'); + }); + + this.#eventBus.addEventListener(`ui:block-settings:closed`, () => { + this.#isBlockSettingsOpen = false; + this.#nodes.settingsButton.setAttribute('aria-expanded', 'false'); + }); + } + + /** + * Asks the Block Settings plugin to open the menu for the block the toolbar is following + */ + #openBlockSettings(): void { + this.#eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: this.#selectedBlockIndex })); } /** From f92a9df80192b081a8f94eec5256f720fbb4fa72 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 01:30:52 +0100 Subject: [PATCH 30/49] feat(default-block-settings-plugin): move, move and delete as a plugin Move up, Move down and Delete become an ordinary plugin registered through the `block-settings` API at `order: 1000`, so the built-in entries sit last in the menu with no privileged path into it. This is the reference example of a former "block tune" written the way any third-party plugin would write it. Every handler resolves the block's current index from its id at activation. The menu can stay open while a collaborator inserts a block above the target or an undo runs, and acting on the index the menu was built with would move a different block. `isDisabled` is necessarily a build-time snapshot, so the handlers carry their own bounds check rather than trusting it -- a stale disabled state then only greys an item out. Delete asks first: the item turns into its own confirmation in place, and only the confirmation carries a handler. It deletes by id, not by position. The plugin looks `block-settings` up lazily at `core:ready` and stays inert when it is absent, which is what a headless Core gets. `destroy()` takes the provider back out of the menu. Behaviour and test cases ported from the internal tunes in PR #157, with the confirmation step they lacked. Co-authored-by: Betty Steger <244475+bettysteger@users.noreply.github.com> Co-Authored-By: Claude Opus 5 --- .../default-block-settings-plugin.yml | 20 + openspec/changes/block-settings-ui/tasks.md | 11 +- .../default-block-settings-plugin/.gitignore | 24 ++ .../default-block-settings-plugin/README.md | 32 ++ .../eslint.config.mjs | 39 ++ .../jest.config.ts | 31 ++ .../package.json | 44 ++ .../src/index.spec.ts | 402 ++++++++++++++++++ .../src/index.ts | 176 ++++++++ .../stryker.conf.mjs | 35 ++ .../tsconfig.build.json | 13 + .../tsconfig.eslint.json | 17 + .../tsconfig.json | 27 ++ 13 files changed, 866 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/default-block-settings-plugin.yml create mode 100644 packages/plugins/default-block-settings-plugin/.gitignore create mode 100644 packages/plugins/default-block-settings-plugin/README.md create mode 100644 packages/plugins/default-block-settings-plugin/eslint.config.mjs create mode 100644 packages/plugins/default-block-settings-plugin/jest.config.ts create mode 100644 packages/plugins/default-block-settings-plugin/package.json create mode 100644 packages/plugins/default-block-settings-plugin/src/index.spec.ts create mode 100644 packages/plugins/default-block-settings-plugin/src/index.ts create mode 100644 packages/plugins/default-block-settings-plugin/stryker.conf.mjs create mode 100644 packages/plugins/default-block-settings-plugin/tsconfig.build.json create mode 100644 packages/plugins/default-block-settings-plugin/tsconfig.eslint.json create mode 100644 packages/plugins/default-block-settings-plugin/tsconfig.json 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/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index 08924400..503ea17c 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -60,17 +60,18 @@ ## 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 +- [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 - [ ] 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 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..5cb05afc --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/package.json @@ -0,0 +1,44 @@ +{ + "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": "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..ba7d84b6 --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/src/index.spec.ts @@ -0,0 +1,402 @@ +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), + move, + delete: remove, + }, + document: { + get data() { + return { blocks: blocks.map(id => ({ id, + name: 'paragraph' })) }; + }, + }, + // 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); + }, + 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'); + }); + }); + + 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..b67e5860 --- /dev/null +++ b/packages/plugins/default-block-settings-plugin/src/index.ts @@ -0,0 +1,176 @@ +import type { + BlockId, + EditorAPI, + EditorjsPlugin, + EditorjsPluginParams +} from '@editorjs/sdk'; +import { CoreEventType, PluginType } from '@editorjs/sdk'; +import { IconChevronDown, IconChevronUp, IconCross, IconTrash } from '@codexteam/icons'; +import type { BlockSettingsContext, MenuConfig } 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; + + /** + * 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; + + eventBus.addEventListener(`core:${CoreEventType.Ready}`, () => { + this.#register(); + }); + } + + /** + * Takes the provider back out of the menu + */ + public destroy(): void { + this.#unregister?.(); + this.#unregister = undefined; + } + + /** + * 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): MenuConfig { + const lastIndex = this.#api.document.data.blocks.length - 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.document.data.blocks.length - 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" + } + ] +} From 227eece1748d030d3632f72ea6a50703670c5359 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 01:37:21 +0100 Subject: [PATCH 31/49] feat(editorjs): ship the block settings menu in the bundle Registers `BlockSettingsUI` and `DefaultBlockSettingsPlugin` so a bundle user gets the settings menu without wiring anything. A headless `Core` still opts in. Adds the e2e suite for it, which exercises the whole round trip through the real bundle in Chromium and WebKit: opening the menu for a hovered block, the button's `aria-expanded` and focus return, the disabled state at the document boundary, moving a block, and that deletion needs its confirmation and is abandoned when the menu is dismissed. Documents the provider API in docs/plugins.md -- including why a handler must resolve the block's position from `blockId` rather than reusing `blockIndex` -- and the four `ui:block-settings:*` events in docs/events.md. Note that the bundle's spec assertions read `ctor.name`, which for a plugin declaring a static `name` is its plugin id rather than the class name; the test mock declares one too, so it asserts a name the real module actually reports. Co-Authored-By: Claude Opus 5 --- docs/events.md | 9 ++ docs/plugins.md | 56 ++++++++++- openspec/changes/block-settings-ui/tasks.md | 11 ++- .../editorjs/e2e/tests/block-settings.spec.ts | 96 +++++++++++++++++++ packages/editorjs/package.json | 1 + packages/editorjs/src/index.spec.ts | 25 +++++ packages/editorjs/src/index.ts | 5 +- yarn.lock | 29 ++++++ 8 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 packages/editorjs/e2e/tests/block-settings.spec.ts diff --git a/docs/events.md b/docs/events.md index 635923e2..f0d40582 100644 --- a/docs/events.md +++ b/docs/events.md @@ -47,9 +47,18 @@ 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` | +| `BlockSettingsOpenUIEvent` | `ui:block-settings:open` | `{ index }` | `ToolbarUI` (settings button) | +| `BlockSettingsRenderedUIEvent` | `ui:block-settings:rendered` | `{ blockSettings: HTMLElement }` | `BlockSettingsUI` | +| `BlockSettingsOpenedUIEvent` | `ui:block-settings:opened` | `{ blockId }` | `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. +The block settings events form one round trip: the toolbar asks for a block *by index*, and +`BlockSettingsUI` answers with `opened` carrying the block *id* it resolved. Only the id is +durable — see [Block settings](plugins.md#block-settings) for why a menu item should resolve +positions from it rather than reusing the index it was built with. + Use core/UI events for UI workflows and extension coordination. ## Adapter internals diff --git a/docs/plugins.md b/docs/plugins.md index cce3fb35..beea070c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -28,7 +28,8 @@ Tools are registered via `core.use(ToolConstructor, options)` during setup. The | Inline Tool | `InlineTool` (from config `tools`) | Selection formatting actions | 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 and stores its state as [per-block plugin data](#per-block-plugin-data). +[block settings menu](#block-settings) and stores its state as +[per-block plugin data](#per-block-plugin-data). ## Initialization sequence @@ -116,6 +117,59 @@ 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 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, 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. + +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 diff --git a/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index 503ea17c..99a3c043 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -72,14 +72,15 @@ - 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 - [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 -- [ ] 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.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 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..0c33553b --- /dev/null +++ b/packages/editorjs/e2e/tests/block-settings.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test'; +import { mountDocument } from '../support/editor.js'; + +/** + * 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('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('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('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..91e627f2 100644 --- a/packages/editorjs/package.json +++ b/packages/editorjs/package.json @@ -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/yarn.lock b/yarn.lock index 3f5a408b..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:^" From 596a97d581b1d55393bef5f0a3677fd53e902248 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 01:49:24 +0100 Subject: [PATCH 32/49] fix(ui): let block settings rows fill the menu The menu items were configured with `wrapperTag: 'button'`, carried over from PR #157. That is what ui-kit uses for its *inline* popover, where items are horizontal toolbar buttons and shrinking to fit the label is right. A vertical menu's styles assume a block-level wrapper, and a form control does not stretch to its container the way a div does, so each row came out as wide as its own label -- roughly 90 / 108 / 76 px for Move up / Move down / Delete. The hover highlight then covered a ragged part of each row instead of the whole row, and the UA button font replaced the editor's (13.3px Arial for 15px InterVariable). Leaving the wrapper at ui-kit's default fixes all three. Items are still exposed as `menuitem` and still activated from the keyboard by ui-kit itself, so nothing is lost on the accessibility side. Guarded by an e2e test, since only a real layout engine sees this. It measures the rows in a single pass in the page and after the webfont has settled -- taken one locator at a time, the rows are measured at different moments and a font still loading moves the menu's width between them, which WebKit reported as several pixels of drift. Confirmed the test fails with the old wrapper restored. Co-Authored-By: Claude Opus 5 --- .../editorjs/e2e/tests/block-settings.spec.ts | 37 +++++++++++++++++++ .../ui/src/BlockSettings/BlockSettings.ts | 36 ++++++++++-------- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/packages/editorjs/e2e/tests/block-settings.spec.ts b/packages/editorjs/e2e/tests/block-settings.spec.ts index 0c33553b..32e7e2d9 100644 --- a/packages/editorjs/e2e/tests/block-settings.spec.ts +++ b/packages/editorjs/e2e/tests/block-settings.spec.ts @@ -1,6 +1,9 @@ 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; + /** * 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. @@ -46,6 +49,40 @@ test('reports the menu state on the button that owns it', async ({ page }) => { await expect(settingsButton).toBeFocused(); }); +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. ui-kit renders menu items as block-level elements + // whose width fills the menu; configuring them as `button` instead — correct for its inline + // toolbar, where items sit in a row — made each one shrink to its own label, so the hover + // highlight covered a ragged part of the row. Only a real layout engine catches this, which + // is why it is asserted here rather than in the jsdom suite. + // + // Measured in one pass in the page, after the webfont has settled: taken one locator at a + // time, the rows are measured at different moments and a font still loading changes the + // menu's width between them, which WebKit reports as several pixels of drift. + 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(); diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index 4e4f120b..7fc84bfb 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -317,23 +317,27 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { #renderPopover(items: PopoverItemParams[]): void { this.#popover?.destroy(); - 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, - }, + /** + * The item wrapper is left at ui-kit's default. `wrapperTag: 'button'` -- which the port + * from PR #157 carried over -- is what ui-kit uses for its *inline* popover, where items + * are horizontal toolbar buttons and shrink-to-fit is the right width. A vertical menu's + * styles assume a block-level wrapper, and a form control does not stretch to its + * container the way a div does, so each row ended up as wide as its own label: the hover + * highlight covered part of the row, and the UA button font replaced the editor's. + * Items are still exposed as `menuitem` and activated from the keyboard by ui-kit itself. + */ + 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, }, - { - [PopoverItemType.Default]: { wrapperTag: 'button' }, - } - ); + }); this.#popover.on(PopoverEvent.Closed, () => { this.#eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); From 36fab79c4f891c1c6b1238f7ea31279567d2a81c Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 01:54:55 +0100 Subject: [PATCH 33/49] test(editorjs): cover the block settings menu's accessibility The menu's items are divs carrying role="menuitem", which is what the ARIA menu pattern calls for and what the toolbox already does -- but it does mean every name, role and state on them is authored rather than inherited from a native control. Nothing was asserting that. Adds an axe audit of the menu in its open state, a keyboard-only walk through it (arrow keys, roving tabindex, Enter activating and closing), and an assertion that an unavailable action is exposed with aria-disabled while staying reachable by the arrow keys, as the APG asks. Co-Authored-By: Claude Opus 5 --- packages/editorjs/e2e/tests/axe.spec.ts | 13 +++++++ .../editorjs/e2e/tests/block-settings.spec.ts | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+) 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 index 32e7e2d9..2a30366e 100644 --- a/packages/editorjs/e2e/tests/block-settings.spec.ts +++ b/packages/editorjs/e2e/tests/block-settings.spec.ts @@ -94,6 +94,42 @@ test('disables moving further at the document boundaries', async ({ page }) => { await expect(menu.getByRole('menuitem', { name: 'Move down' })).toBeEnabled(); }); +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('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('moves a block up from the settings menu', async ({ page }) => { const blocks = page.getByRole('textbox', { name: 'Paragraph' }); From 40b531453178911d4208d29f1c9f65e66bdeefd4 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 22:16:50 +0100 Subject: [PATCH 34/49] fix(ui): make the block settings menu robust to its own providers Three defects found reviewing the change, all from `BlockSettingsUI` trusting input it has no business trusting. A provider that threw took the whole menu down. Providers are third-party code and `#collectItems` ran all of them inside one `Promise.all`, so a single plugin's error rejected the lot, was discarded by the fire-and-forget call in the constructor, and left the menu failing to open for every block -- losing the entries of the plugins that did nothing wrong. Each provider is now isolated and a failure is reported the way `BlockRenderer` reports a tool that fails to render: it contributes nothing, and the stack says which one it was. A request that resolved to no block, or one every provider opted out of, returned without touching a menu that was already open. That menu had been built for a different block, so it stayed on screen presenting one block's settings as another's. Both paths now close it. Building a menu is asynchronous, so two overlapping requests raced and whichever finished last won. With an async provider the stale build could land second and render the wrong block, reporting a `blockId` nobody asked for. Each request now takes a token and abandons its result if a newer request has started. Each case is covered by a test that fails without the corresponding fix. Co-Authored-By: Claude Opus 5 --- .../src/BlockSettings/BlockSettings.spec.ts | 143 +++++++++++++++++- .../ui/src/BlockSettings/BlockSettings.ts | 46 +++++- 2 files changed, 183 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts index eaf0f58e..6e8efff2 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.spec.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +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'; @@ -30,6 +30,12 @@ const defaultBlocks: StubBlock[] = [ */ 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; + /** * 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 @@ -397,6 +403,141 @@ describe('BlockSettingsUI', () => { }); }); + 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']); + }); + + 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 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 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); + expect((onOpened.mock.calls[0][0] as CustomEvent).detail).toEqual({ blockId: 'block-1' }); + }); + }); + describe('accessibility', () => { it('should name the menu from the message catalogue', async () => { instance.plugin.publicApi.register(() => ({ diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index 7fc84bfb..fd2a99de 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -177,6 +177,12 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { */ #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; + /** * BlockSettingsUI class constructor * @param params - Plugin parameters @@ -245,13 +251,17 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { * @param index - position the opening request named */ async #open(index: number): Promise { + const token = ++this.#openToken; const blockId = this.#api.blocks.getIdByIndex(index); /** * Nothing is selected, or the index is stale. Providers are not asked about a block that - * is not there, and an opening request for one is simply dropped + * is not there. Any menu still on screen was built for a different block, so leaving it + * open would show one block's settings while claiming to be another's */ if (blockId === undefined) { + this.#close(); + return; } @@ -261,7 +271,21 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { tool: this.#api.document.data.blocks[index]?.name ?? '', }); + /** + * 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; + } + + /** + * 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; } @@ -284,13 +308,25 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { const contributions = await Promise.all( ordered.map(async ({ provider }) => { - const result = await provider(context); + try { + const result = await provider(context); + + if (result === undefined) { + return []; + } + + return Array.isArray(result) ? result : [result]; + } catch (error) { + /** + * Providers are third-party code, and this is the one place all of it runs. Without + * isolation a single plugin throwing takes the whole menu down for every block -- + * including the entries of the plugins that did nothing wrong. It contributes + * nothing instead, and the stack identifies which one it was. + */ + console.error('[BlockSettingsUI] A block settings provider failed and was skipped', error); - if (result === undefined) { return []; } - - return Array.isArray(result) ? result : [result]; }) ); From 451a090dd84ed41b4723c68595e10b3a3024b188 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 22:36:22 +0100 Subject: [PATCH 35/49] test(editorjs): group the block settings e2e tests The suite was nine top-level `test()` calls. Grouped into `test.describe` blocks -- opening the menu, presentation, keyboard operation, actions -- to match how `aria.spec.ts` groups its toolbox and inline toolbar cases, and the describe/it structure the unit suites already follow. No assertions changed. Co-Authored-By: Claude Opus 5 --- .../editorjs/e2e/tests/block-settings.spec.ts | 232 +++++++++--------- 1 file changed, 120 insertions(+), 112 deletions(-) diff --git a/packages/editorjs/e2e/tests/block-settings.spec.ts b/packages/editorjs/e2e/tests/block-settings.spec.ts index 2a30366e..74c7ae92 100644 --- a/packages/editorjs/e2e/tests/block-settings.spec.ts +++ b/packages/editorjs/e2e/tests/block-settings.spec.ts @@ -13,157 +13,165 @@ test.beforeEach(async ({ page }) => { await mountDocument(page, '?text=Alpha&text=Beta'); }); -test('opens the settings menu for the hovered block', async ({ page }) => { - const blocks = page.getByRole('textbox', { name: 'Paragraph' }); +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(); + 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' }); + // 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(); -}); + 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' }); + 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 page.getByRole('textbox', { name: 'Paragraph' }).first() + .hover(); - await expect(settingsButton).toHaveAttribute('aria-haspopup', 'menu'); - await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); + await expect(settingsButton).toHaveAttribute('aria-haspopup', 'menu'); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); - await settingsButton.click(); + await settingsButton.click(); - await expect(settingsButton).toHaveAttribute('aria-expanded', 'true'); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'true'); - await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); - await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); + 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(); + // The menu button pattern hands focus back to the button the menu was opened from. + await expect(settingsButton).toBeFocused(); + }); }); -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. ui-kit renders menu items as block-level elements - // whose width fills the menu; configuring them as `button` instead — correct for its inline - // toolbar, where items sit in a row — made each one shrink to its own label, so the hover - // highlight covered a ragged part of the row. Only a real layout engine catches this, which - // is why it is asserted here rather than in the jsdom suite. - // - // Measured in one pass in the page, after the webfont has settled: taken one locator at a - // time, the rows are measured at different moments and a font still loading changes the - // menu's width between them, which WebKit reports as several pixels of drift. - 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); +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. ui-kit renders menu items as block-level elements + // whose width fills the menu; configuring them as `button` instead — correct for its inline + // toolbar, where items sit in a row — made each one shrink to its own label, so the hover + // highlight covered a ragged part of the row. Only a real layout engine catches this, which + // is why it is asserted here rather than in the jsdom suite. + // + // Measured in one pass in the page, after the webfont has settled: taken one locator at a + // time, the rows are measured at different moments and a font still loading changes the + // menu's width between them, which WebKit reports as several pixels of drift. + 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); }); - 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(); -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' }); - 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(); -}); + await expect(menu.getByRole('menuitem', { name: 'Move up' })).toBeDisabled(); + await expect(menu.getByRole('menuitem', { name: 'Move down' })).toBeEnabled(); + }); -test('is operable from the keyboard alone', async ({ page }) => { - const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + 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(); - await blocks.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' }); - const menu = page.getByRole('menu', { name: 'Block settings' }); + await expect(moveUp).toHaveAttribute('aria-disabled', 'true'); + }); +}); - await expect(menu).toBeVisible(); +test.describe('keyboard operation', () => { + test('is operable from the keyboard alone', async ({ page }) => { + const blocks = page.getByRole('textbox', { name: 'Paragraph' }); - // 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 blocks.first().hover(); + await page.getByRole('button', { name: 'Block settings' }).click(); - await expect(menu.getByRole('menuitem', { name: 'Move down' })).toBeFocused(); + const menu = page.getByRole('menu', { name: 'Block settings' }); - await page.keyboard.press('Enter'); + await expect(menu).toBeVisible(); - await expect(blocks).toHaveText(['Beta', 'Alpha']); - await expect(menu).toBeHidden(); -}); + // 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'); -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(); + await expect(menu.getByRole('menuitem', { name: 'Move down' })).toBeFocused(); - // 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 page.keyboard.press('Enter'); - await expect(moveUp).toHaveAttribute('aria-disabled', 'true'); + await expect(blocks).toHaveText(['Beta', 'Alpha']); + await expect(menu).toBeHidden(); + }); }); -test('moves a block up from the settings menu', async ({ page }) => { - const blocks = page.getByRole('textbox', { name: 'Paragraph' }); +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 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 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']); -}); + 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' }); + 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(); + 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']); + // 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 page.getByRole('menuitem', { name: 'Click to delete' }).click(); - await expect(blocks).toHaveText(['Alpha']); -}); + await expect(blocks).toHaveText(['Alpha']); + }); -test('abandons a pending deletion when the menu is dismissed', async ({ page }) => { - const blocks = page.getByRole('textbox', { name: 'Paragraph' }); + 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 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']); + await expect(blocks).toHaveText(['Alpha', 'Beta']); + }); }); From 43ff8ae6342e91b92ad19c00c7627fe4921a8b3a Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 23:17:58 +0100 Subject: [PATCH 36/49] refactor(ui)!: let the block settings plugin own its button `ToolbarUI` was rendering the settings button, naming it, tracking its `aria-expanded`, following block selection on its behalf and dispatching `ui:block-settings:open` on click -- all of it state belonging to a plugin that may not even be registered. The button and the menu are one widget, so the plugin now builds both and announces them together through `ui:block-settings:rendered`; the toolbar only places them. The button is announced separately from the menu rather than wrapped inside it, because it has to be a child of the actions container in its own right for the toolbar's roving tabindex to see it. That arrives after the toolbar has rendered, so mounting reassigns the single tab stop -- a button carrying no `tabindex` would otherwise be a second one. `ui:block-settings:open` stops being how the button works and becomes the way anything else opens the menu. The toolbar keeps `opened`/`closed` for the one thing that is still its business: holding position while a menu built for one block is on screen. The `opened` event drops its `blockId` payload, which nothing consumed. BREAKING: `ui:block-settings:rendered` now carries `{ button, blockSettings }`, and `ui:block-settings:opened` carries nothing. Co-Authored-By: Claude Opus 5 --- docs/events.md | 14 +-- docs/plugins.md | 5 +- .../BlockSettings/BlockSettings.module.pcss | 28 +++++ .../src/BlockSettings/BlockSettings.spec.ts | 110 ++++++++++++++++- .../ui/src/BlockSettings/BlockSettings.ts | 61 ++++++++- .../events/BlockSettingsOpenedUIEvent.ts | 11 +- .../events/BlockSettingsRenderedUIEvent.ts | 13 +- packages/ui/src/Toolbar/Toolbar.const.ts | 1 - packages/ui/src/Toolbar/Toolbar.module.pcss | 6 - packages/ui/src/Toolbar/Toolbar.spec.ts | 116 +++++++++--------- packages/ui/src/Toolbar/Toolbar.ts | 77 +++--------- 11 files changed, 292 insertions(+), 150 deletions(-) create mode 100644 packages/ui/src/BlockSettings/BlockSettings.module.pcss diff --git a/docs/events.md b/docs/events.md index f0d40582..5919ac22 100644 --- a/docs/events.md +++ b/docs/events.md @@ -47,17 +47,17 @@ 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` | -| `BlockSettingsOpenUIEvent` | `ui:block-settings:open` | `{ index }` | `ToolbarUI` (settings button) | -| `BlockSettingsRenderedUIEvent` | `ui:block-settings:rendered` | `{ blockSettings: HTMLElement }` | `BlockSettingsUI` | -| `BlockSettingsOpenedUIEvent` | `ui:block-settings:opened` | `{ blockId }` | `BlockSettingsUI` | +| `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. -The block settings events form one round trip: the toolbar asks for a block *by index*, and -`BlockSettingsUI` answers with `opened` carrying the block *id* it resolved. Only the id is -durable — see [Block settings](plugins.md#block-settings) for why a menu item should resolve -positions from it rather than reusing the index it was built with. +`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. Use core/UI events for UI workflows and extension coordination. diff --git a/docs/plugins.md b/docs/plugins.md index beea070c..a01db8db 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -120,8 +120,9 @@ 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 owns no items of its own; a plugin contributes to it by -registering a *provider*: +`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 => ({ diff --git a/packages/ui/src/BlockSettings/BlockSettings.module.pcss b/packages/ui/src/BlockSettings/BlockSettings.module.pcss new file mode 100644 index 00000000..abd3f437 --- /dev/null +++ b/packages/ui/src/BlockSettings/BlockSettings.module.pcss @@ -0,0 +1,28 @@ +/** + * The button is rendered by this plugin but lives among the toolbar's controls, so it + * matches them. The declarations are repeated rather than shared because `@apply` of the + * toolbar's `--toolbar-button` property set resolves per file, and there is no shared + * partial for the two to draw on yet. + */ +.settings-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/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts index 6e8efff2..85850939 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.spec.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -4,6 +4,7 @@ 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 type { BlockSettingsProvider } from './BlockSettings.js'; /** @@ -55,6 +56,8 @@ function setup(blocks: StubBlock[] = defaultBlocks): { 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` */ @@ -82,13 +85,17 @@ function setup(blocks: StubBlock[] = defaultBlocks): { } 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 { - element = (event.detail as BlockSettingsRenderedUIEventPayload).blockSettings; + const detail = event.detail as BlockSettingsRenderedUIEventPayload; + + element = detail.blockSettings; + button = detail.button as HTMLButtonElement; } eventBus.addEventListener('ui:block-settings:rendered', captureElement); @@ -103,16 +110,24 @@ function setup(blocks: StubBlock[] = defaultBlocks): { config: { holder } as unknown as CoreConfigValidated, }); - if (element === undefined) { - throw new Error('BlockSettingsUI did not announce its element'); + 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, }; @@ -343,7 +358,6 @@ describe('BlockSettingsUI', () => { await open(instance.eventBus, 1); expect(onOpened).toHaveBeenCalledTimes(1); - expect((onOpened.mock.calls[0][0] as CustomEvent).detail).toEqual({ blockId: 'block-1' }); }); it('should dispatch closed when closed through the public API', async () => { @@ -534,7 +548,93 @@ describe('BlockSettingsUI', () => { expect(itemTitles(instance.element)).toEqual(['For block-1']); expect(onOpened).toHaveBeenCalledTimes(1); - expect((onOpened.mock.calls[0][0] as CustomEvent).detail).toEqual({ blockId: 'block-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, + })); + } + + it('should announce a button alongside the menu', () => { + expect(instance.button.tagName).toBe('BUTTON'); + }); + + 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); + instance.button.click(); + 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); + instance.button.click(); + 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); + instance.button.click(); + 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 not re-target while its menu is open', async () => { + instance.plugin.publicApi.register(ctx => ({ + title: `For ${ctx.blockId}`, + onActivate: () => {}, + })); + + selectBlock(0); + instance.button.click(); + await flush(); + + // The menu belongs to block 0; hovering elsewhere must not move it underneath. + selectBlock(2); + instance.plugin.publicApi.close(); + instance.button.click(); + await flush(); + + expect(itemTitles(instance.element)).toEqual(['For block-0']); }); }); diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index fd2a99de..0f99d760 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -1,4 +1,5 @@ import { make } from '@editorjs/dom'; +import { IconMenuSmall } from '@codexteam/icons'; import type { BlockId, CoreConfigValidated, @@ -11,6 +12,8 @@ import { UiComponentType } from '@editorjs/sdk'; import type { PopoverItemParams } from '@editorjs/ui-kit'; import { PopoverDesktop, PopoverEvent, PopoverItemType } from '@editorjs/ui-kit'; import { messages } from '../messages.js'; +import Style from './BlockSettings.module.pcss'; +import type { BlockSelectedUIEvent } from '../Blocks/events/index.js'; import { BlockSettingsClosedUIEvent, BlockSettingsOpenedUIEvent, @@ -157,11 +160,27 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { #editorConfig: CoreConfigValidated; /** - * Stable element handed to `ToolbarUI`. The popover inside it is replaced per open, + * 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; + /** * Popover rendering the current menu, absent until the menu is first built */ @@ -198,11 +217,25 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { }; this.#holder = make('div'); + this.#button = make('button', Style['settings-button'], { + innerHTML: IconMenuSmall, + }) as HTMLButtonElement; + + this.#renderButton(); this.#eventBus.dispatchEvent(new BlockSettingsRenderedUIEvent({ + button: this.#button, blockSettings: this.#holder, })); + this.#eventBus.addEventListener('ui:blocks:block-selected', (event: BlockSelectedUIEvent) => { + if (this.#popover?.isShown === true) { + return; + } + + this.#selectedBlockIndex = event.detail.index; + }); + this.#eventBus.addEventListener('ui:block-settings:open', (event: BlockSettingsOpenUIEvent) => { void this.#open(event.detail.index); }); @@ -215,6 +248,27 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { this.#popover?.destroy(); this.#popover = undefined; this.#holder.remove(); + this.#button.remove(); + } + + /** + * 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('click', () => { + /** + * 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(); + + void this.#open(this.#selectedBlockIndex); + }); } /** @@ -292,8 +346,9 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { this.#renderPopover(items); this.#popover?.show(); + this.#button.setAttribute('aria-expanded', 'true'); - this.#eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({ blockId })); + this.#eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({})); } /** @@ -376,6 +431,8 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { }); this.#popover.on(PopoverEvent.Closed, () => { + this.#button.setAttribute('aria-expanded', 'false'); + this.#eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); }); diff --git a/packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts b/packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts index d90a27ab..4166e5e3 100644 --- a/packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts +++ b/packages/ui/src/BlockSettings/events/BlockSettingsOpenedUIEvent.ts @@ -1,15 +1,10 @@ -import type { BlockId } from '@editorjs/sdk'; import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; /** - * Reports which block the menu that just opened was built for + * Payload of the BlockSettingsOpenedUIEvent - empty for now */ -export interface BlockSettingsOpenedUIEventPayload { - /** - * Id of the block the opened menu was built for - */ - readonly blockId: BlockId; -} +// 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 diff --git a/packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts b/packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts index 4bd4bd3c..c7371465 100644 --- a/packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts +++ b/packages/ui/src/BlockSettings/events/BlockSettingsRenderedUIEvent.ts @@ -2,11 +2,20 @@ import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; /** * Payload of the BlockSettingsRenderedUIEvent - * Contains the block settings HTML element + * Carries the elements the plugin owns, for a host to mount */ export interface BlockSettingsRenderedUIEventPayload { /** - * Block settings HTML element + * 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; } diff --git a/packages/ui/src/Toolbar/Toolbar.const.ts b/packages/ui/src/Toolbar/Toolbar.const.ts index 658273ec..02a9b221 100644 --- a/packages/ui/src/Toolbar/Toolbar.const.ts +++ b/packages/ui/src/Toolbar/Toolbar.const.ts @@ -6,5 +6,4 @@ export const css = { toolbar: className(), actions: className('actions'), plusButton: className('plus-button'), - settingsButton: className('settings-button'), }; diff --git a/packages/ui/src/Toolbar/Toolbar.module.pcss b/packages/ui/src/Toolbar/Toolbar.module.pcss index 0cab49f4..c900d4fb 100644 --- a/packages/ui/src/Toolbar/Toolbar.module.pcss +++ b/packages/ui/src/Toolbar/Toolbar.module.pcss @@ -51,10 +51,4 @@ flex-shrink: 0; } - - &__settings-button { - @apply --toolbar-button; - - flex-shrink: 0; - } } diff --git a/packages/ui/src/Toolbar/Toolbar.spec.ts b/packages/ui/src/Toolbar/Toolbar.spec.ts index 20331ef0..c7b3c849 100644 --- a/packages/ui/src/Toolbar/Toolbar.spec.ts +++ b/packages/ui/src/Toolbar/Toolbar.spec.ts @@ -60,19 +60,31 @@ function actionButtons(holder: HTMLElement): HTMLButtonElement[] { } /** - * The button that opens block settings - * @param holder - the toolbar element + * 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 settingsButton(holder: HTMLElement): HTMLButtonElement { - const button = actionButtons(holder).find( - candidate => candidate.getAttribute('aria-label') === messages.blockSettingsButton - ); +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'); - if (button === undefined) { - throw new Error('the toolbar rendered no settings button'); - } + button.setAttribute('aria-label', messages.blockSettingsButton); + menu.dataset.testid = 'block-settings-menu'; - return button; + eventBus.dispatchEvent(new BlockSettingsRenderedUIEvent({ + button, + blockSettings: menu, + })); + + return { + button, + menu, + }; } /** @@ -108,61 +120,42 @@ describe('ToolbarUI', () => { }); }); - describe('settings button', () => { - it('should request settings for the block the toolbar is following', () => { - const onOpen = jest.fn(); - - instance.eventBus.addEventListener('ui:block-settings:open', onOpen); - - selectBlock(instance.eventBus, 2); - settingsButton(instance.holder).click(); + describe('mounting an announced widget', () => { + it('should mount the announced menu element into its actions area', () => { + const { menu } = announceBlockSettings(instance.eventBus); - expect(onOpen).toHaveBeenCalledTimes(1); - expect((onOpen.mock.calls[0][0] as CustomEvent).detail).toEqual({ index: 2 }); + expect(instance.holder.querySelector('[role="toolbar"] [data-testid="block-settings-menu"]')).toBe(menu); }); - it('should carry an accessible name and advertise the menu it controls', () => { - const button = settingsButton(instance.holder); + it('should mount the announced button as a control of its own', () => { + const { button } = announceBlockSettings(instance.eventBus); - expect(button.getAttribute('aria-label')).toBe(messages.blockSettingsButton); - expect(button.getAttribute('aria-haspopup')).toBe('menu'); - expect(button.getAttribute('aria-expanded')).toBe('false'); + // 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 follow the menu state with aria-expanded', () => { - const button = settingsButton(instance.holder); - - instance.eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({ blockId: 'block-2' as never })); - expect(button.getAttribute('aria-expanded')).toBe('true'); + it('should keep one tab stop after an announced button joins', () => { + announceBlockSettings(instance.eventBus); - instance.eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); - expect(button.getAttribute('aria-expanded')).toBe('false'); - }); - - it('should hold focus before the menu is asked to open', () => { - const button = settingsButton(instance.holder); - let focusedAtDispatch: Element | null = null; - - instance.eventBus.addEventListener('ui:block-settings:open', () => { - focusedAtDispatch = document.activeElement; - }); - - selectBlock(instance.eventBus, 0); - button.click(); + const buttons = actionButtons(instance.holder); - expect(focusedAtDispatch).toBe(button); + expect(buttons).toHaveLength(2); + expect(buttons.filter(button => button.tabIndex === 0)).toHaveLength(1); }); - }); - describe('mounting the menu', () => { - it('should mount the announced popover element into its actions area', () => { - const popover = document.createElement('div'); + it('should let the arrow keys reach an announced button', () => { + const { button } = announceBlockSettings(instance.eventBus); + const [plusButton] = actionButtons(instance.holder); - popover.dataset.testid = 'block-settings-popover'; - - instance.eventBus.dispatchEvent(new BlockSettingsRenderedUIEvent({ blockSettings: popover })); + plusButton.focus(); + plusButton.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + })); - expect(instance.holder.querySelector('[role="toolbar"] [data-testid="block-settings-popover"]')).toBe(popover); + expect(button.tabIndex).toBe(0); + expect(plusButton.tabIndex).toBe(-1); }); }); @@ -170,7 +163,7 @@ describe('ToolbarUI', () => { it('should not move while block settings are open', () => { const moveTo = jest.spyOn(instance.plugin, 'moveTo'); - instance.eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({ blockId: 'block-0' as never })); + instance.eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({})); selectBlock(instance.eventBus, 1); expect(moveTo).not.toHaveBeenCalled(); @@ -184,6 +177,8 @@ describe('ToolbarUI', () => { 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); @@ -191,17 +186,18 @@ describe('ToolbarUI', () => { expect(buttons.filter(button => button.tabIndex === -1)).toHaveLength(buttons.length - 1); }); - it('should move the tab stop with the arrow keys', () => { - const buttons = actionButtons(instance.holder); + it('should wrap around a single control', () => { + const [plusButton] = actionButtons(instance.holder); - buttons[0].focus(); - buttons[0].dispatchEvent(new KeyboardEvent('keydown', { + plusButton.focus(); + plusButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, })); - expect(buttons[1].tabIndex).toBe(0); - expect(buttons[0].tabIndex).toBe(-1); + // 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 e29f1d1b..90238cb9 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -3,13 +3,12 @@ import { UiComponentType } from '@editorjs/sdk'; import { make } from '@editorjs/dom'; import { css } from './Toolbar.const.js'; import type { ToolboxRenderedUIEvent } from '../Toolbox/events/index.js'; -import { IconMenuSmall, IconPlus } from '@codexteam/icons'; +import { IconPlus } from '@codexteam/icons'; import Style from './Toolbar.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 { BlockSettingsOpenUIEvent } from '../BlockSettings/events/index.js'; import { messages } from '../messages.js'; /** @@ -40,11 +39,6 @@ interface ToolbarNodes { * Plus button to open Toolbox popover */ plusButton: HTMLButtonElement; - - /** - * Settings button to open the Block Settings popover - */ - settingsButton: HTMLButtonElement; } /** @@ -77,9 +71,6 @@ export class ToolbarUI implements EditorjsPlugin { plusButton: make('button', Style[css.plusButton], { innerHTML: IconPlus, }) as HTMLButtonElement, - settingsButton: make('button', Style[css.settingsButton], { - innerHTML: IconMenuSmall, - }) as HTMLButtonElement, }; /** @@ -93,14 +84,6 @@ export class ToolbarUI implements EditorjsPlugin { */ #isBlockSettingsOpen = false; - /** - * Position of the block the toolbar is currently following. - * - * `-1` until a block is reported, which is what the settings request carries in that case: - * it resolves to no block, and `BlockSettingsUI` drops the request - */ - #selectedBlockIndex = -1; - /** * Constructor function * @param args - plugin parameters @@ -127,8 +110,6 @@ export class ToolbarUI implements EditorjsPlugin { return; } - this.#selectedBlockIndex = event.detail.index; - this.moveTo(event.detail.block); }, { signal }); } @@ -166,11 +147,22 @@ export class ToolbarUI implements EditorjsPlugin { } /** - * Adds the Block Settings popover to the editor UI - * @param blockSettingsElement - block settings HTML element to add to the page + * Mounts the Block Settings widget: its button among the toolbar's own controls, and its + * menu alongside the toolbox popover. + * + * The button has to be a child of the actions container in its own right, not wrapped in + * the menu's element -- that is what puts it in `#controls` and so into the toolbar's + * roving tabindex. The tab stop is reassigned afterwards because this arrives after the + * toolbar has already rendered, and a button carrying no `tabindex` would be a second + * stop next to the plus button + * @param button - the control that opens the menu + * @param blockSettingsElement - the element the menu renders into */ - #addBlockSettings(blockSettingsElement: HTMLElement): void { + #addBlockSettings(button: HTMLElement, blockSettingsElement: HTMLElement): void { + this.#nodes.actions.appendChild(button); this.#nodes.actions.appendChild(blockSettingsElement); + + this.#updateRovingTabindex(this.#nodes.plusButton); } /** @@ -182,12 +174,6 @@ export class ToolbarUI implements EditorjsPlugin { this.#nodes.holder.appendChild(this.#nodes.actions); this.#nodes.actions.appendChild(this.#nodes.plusButton); - /** - * Appended before the roving tabindex is initialized below, so that both buttons are - * already in the container when the single tab stop is assigned - */ - this.#nodes.actions.appendChild(this.#nodes.settingsButton); - /** * The actions container groups related controls. It is named because the inline toolbar * is a toolbar too, and the plus button is icon-only, so it needs a name of its own @@ -196,7 +182,6 @@ export class ToolbarUI implements EditorjsPlugin { this.#nodes.actions.setAttribute('aria-label', messages.blockActionsToolbar); this.#nodes.actions.setAttribute('aria-orientation', 'horizontal'); this.#nodes.plusButton.setAttribute('aria-label', messages.addBlockButton); - this.#nodes.settingsButton.setAttribute('aria-label', messages.blockSettingsButton); /** * role="toolbar" is a promise that the group is a single tab stop navigated with the arrow @@ -215,23 +200,6 @@ export class ToolbarUI implements EditorjsPlugin { this.#nodes.plusButton.setAttribute('aria-haspopup', 'menu'); this.#nodes.plusButton.setAttribute('aria-expanded', 'false'); - /** - * Same menu-button contract as the plus button: it owns the settings menu and reports - * whether that menu is currently open - */ - this.#nodes.settingsButton.setAttribute('aria-haspopup', 'menu'); - this.#nodes.settingsButton.setAttribute('aria-expanded', 'false'); - - this.#nodes.settingsButton.addEventListener('click', () => { - /** - * Focused for the same reason the plus button is: Safari leaves a clicked button - * unfocused, and the popover would then restore focus to `document.body` on close - */ - this.#nodes.settingsButton.focus(); - - this.#openBlockSettings(); - }); - this.#nodes.plusButton.addEventListener('click', () => { /** * Safari does not focus a button when it is clicked, so without this the toolbox @@ -288,27 +256,22 @@ export class ToolbarUI implements EditorjsPlugin { */ #subscribeToBlockSettingsEvents(): void { this.#eventBus.addEventListener(`ui:block-settings:rendered`, (event: BlockSettingsRenderedUIEvent) => { - this.#addBlockSettings(event.detail.blockSettings); + this.#addBlockSettings(event.detail.button, event.detail.blockSettings); }); + /** + * 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; - this.#nodes.settingsButton.setAttribute('aria-expanded', 'true'); }); this.#eventBus.addEventListener(`ui:block-settings:closed`, () => { this.#isBlockSettingsOpen = false; - this.#nodes.settingsButton.setAttribute('aria-expanded', 'false'); }); } - /** - * Asks the Block Settings plugin to open the menu for the block the toolbar is following - */ - #openBlockSettings(): void { - this.#eventBus.dispatchEvent(new BlockSettingsOpenUIEvent({ index: this.#selectedBlockIndex })); - } - /** * The toolbar's own controls, in the order the arrow keys walk them. * From 451120d5639a632e273e4a347dbcb7723736693f Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 23:31:08 +0100 Subject: [PATCH 37/49] refactor(ui): share the toolbar button styling The plus button and the settings button look the same but are rendered by different components, and `@apply --toolbar-button` resolves within a single file's PostCSS pass -- so moving the settings button into its own plugin had meant repeating the declarations. They now share one `.toolbar-button` class in `controls.module.pcss`, which is what the two genuinely have in common. Drops the property set's `--active` modifier along the way. Nothing ever added that class, and the `bounceIn` animation it referenced is not defined anywhere in the package. Verified in the browser as well as the suites: both buttons render identically at 26x26 from the one class, and exactly one of them holds the tab stop. Co-Authored-By: Claude Opus 5 --- .../BlockSettings/BlockSettings.module.pcss | 28 -------------- .../ui/src/BlockSettings/BlockSettings.ts | 4 +- packages/ui/src/Toolbar/Toolbar.const.ts | 1 - packages/ui/src/Toolbar/Toolbar.module.pcss | 37 ------------------- packages/ui/src/Toolbar/Toolbar.ts | 3 +- packages/ui/src/controls.module.pcss | 31 ++++++++++++++++ 6 files changed, 35 insertions(+), 69 deletions(-) delete mode 100644 packages/ui/src/BlockSettings/BlockSettings.module.pcss create mode 100644 packages/ui/src/controls.module.pcss diff --git a/packages/ui/src/BlockSettings/BlockSettings.module.pcss b/packages/ui/src/BlockSettings/BlockSettings.module.pcss deleted file mode 100644 index abd3f437..00000000 --- a/packages/ui/src/BlockSettings/BlockSettings.module.pcss +++ /dev/null @@ -1,28 +0,0 @@ -/** - * The button is rendered by this plugin but lives among the toolbar's controls, so it - * matches them. The declarations are repeated rather than shared because `@apply` of the - * toolbar's `--toolbar-button` property set resolves per file, and there is no shared - * partial for the two to draw on yet. - */ -.settings-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/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index 0f99d760..6b3e5a50 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -12,7 +12,7 @@ import { UiComponentType } from '@editorjs/sdk'; import type { PopoverItemParams } from '@editorjs/ui-kit'; import { PopoverDesktop, PopoverEvent, PopoverItemType } from '@editorjs/ui-kit'; import { messages } from '../messages.js'; -import Style from './BlockSettings.module.pcss'; +import ControlStyle from '../controls.module.pcss'; import type { BlockSelectedUIEvent } from '../Blocks/events/index.js'; import { BlockSettingsClosedUIEvent, @@ -217,7 +217,7 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { }; this.#holder = make('div'); - this.#button = make('button', Style['settings-button'], { + this.#button = make('button', ControlStyle['toolbar-button'], { innerHTML: IconMenuSmall, }) as HTMLButtonElement; 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 c900d4fb..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; @@ -45,10 +14,4 @@ display: flex; align-items: center; } - - &__plus-button { - @apply --toolbar-button; - - flex-shrink: 0; - } } diff --git a/packages/ui/src/Toolbar/Toolbar.ts b/packages/ui/src/Toolbar/Toolbar.ts index 90238cb9..e7d24499 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -5,6 +5,7 @@ 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'; @@ -68,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, }; diff --git a/packages/ui/src/controls.module.pcss b/packages/ui/src/controls.module.pcss new file mode 100644 index 00000000..f0dec69b --- /dev/null +++ b/packages/ui/src/controls.module.pcss @@ -0,0 +1,31 @@ +/** + * Styling shared by the icon buttons that sit in the floating toolbar's actions container. + * + * A plain class rather than a `--toolbar-button` property set applied with `@apply`: that + * resolves within one file's PostCSS pass, so every component rendering one of these buttons + * had to repeat the declarations. The buttons are not all rendered by the same component -- + * `ToolbarUI` owns the plus button, `BlockSettingsUI` owns the settings button -- so the one + * thing they genuinely share is this appearance. + */ +.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); + } + } +} From d849eea8f651a822d848936022ef02bad4d16e29 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 9 Oct 2026 23:52:29 +0100 Subject: [PATCH 38/49] docs: keep the block settings comments to two sentences Eleven comments added by this change ran to three or four. Same content, tightened: the plugin and provider docs, the rebuild-per-open and item-wrapper notes, the toolbar's mounting contract, the shared button stylesheet, the CSS Modules mock and the row-width regression test. Co-Authored-By: Claude Opus 5 --- .../editorjs/e2e/tests/block-settings.spec.ts | 14 +++--- .../ui/src/BlockSettings/BlockSettings.ts | 45 ++++++++----------- .../events/BlockSettingsOpenUIEvent.ts | 8 ++-- packages/ui/src/Toolbar/Toolbar.ts | 12 ++--- packages/ui/src/controls.module.pcss | 11 ++--- 5 files changed, 34 insertions(+), 56 deletions(-) diff --git a/packages/editorjs/e2e/tests/block-settings.spec.ts b/packages/editorjs/e2e/tests/block-settings.spec.ts index 74c7ae92..63ea9dd3 100644 --- a/packages/editorjs/e2e/tests/block-settings.spec.ts +++ b/packages/editorjs/e2e/tests/block-settings.spec.ts @@ -59,15 +59,11 @@ test.describe('presentation', () => { await expect(page.getByRole('menu', { name: 'Block settings' })).toBeVisible(); - // Regression test for the item wrapper. ui-kit renders menu items as block-level elements - // whose width fills the menu; configuring them as `button` instead — correct for its inline - // toolbar, where items sit in a row — made each one shrink to its own label, so the hover - // highlight covered a ragged part of the row. Only a real layout engine catches this, which - // is why it is asserted here rather than in the jsdom suite. - // - // Measured in one pass in the page, after the webfont has settled: taken one locator at a - // time, the rows are measured at different moments and a font still loading changes the - // menu's width between them, which WebKit reports as several pixels of drift. + // 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; diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index 6b3e5a50..1701d0a1 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -72,7 +72,7 @@ export type BlockSettingsProvider = ( */ export interface BlockSettingsRegisterOptions { /** - * Ascending sort key for this provider's contribution. Defaults to `0`, ties are broken by + * 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; @@ -121,12 +121,9 @@ interface Registration { } /** - * UI plugin that renders the per-block settings menu. - * - * Mirrors {@link ToolboxUI}: it announces its element once so `ToolbarUI` can mount it, then - * builds and opens the menu whenever it receives `ui:block-settings:open`. It owns no items of - * its own -- everything in the menu comes from registered providers, the built-in Move/Delete - * entries included. + * 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'> { /** @@ -309,9 +306,9 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { const blockId = this.#api.blocks.getIdByIndex(index); /** - * Nothing is selected, or the index is stale. Providers are not asked about a block that - * is not there. Any menu still on screen was built for a different block, so leaving it - * open would show one block's settings while claiming to be another's + * 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(); @@ -373,10 +370,9 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { return Array.isArray(result) ? result : [result]; } catch (error) { /** - * Providers are third-party code, and this is the one place all of it runs. Without - * isolation a single plugin throwing takes the whole menu down for every block -- - * including the entries of the plugins that did nothing wrong. It contributes - * nothing instead, and the stack identifies which one it was. + * 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); @@ -397,25 +393,20 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { } /** - * Replaces the popover inside the holder with one built for the given items. - * - * The menu is rebuilt rather than edited in place: `removeItemByName` is the only removal - * `@editorjs/ui-kit` offers, and a separator is constructed without params, so it has no name - * to be removed by. Handing a fresh popover the complete item list is what guarantees that - * the items of the previous block leave the accessibility tree. + * 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'` -- which the port - * from PR #157 carried over -- is what ui-kit uses for its *inline* popover, where items - * are horizontal toolbar buttons and shrink-to-fit is the right width. A vertical menu's - * styles assume a block-level wrapper, and a form control does not stretch to its - * container the way a div does, so each row ended up as wide as its own label: the hover - * highlight covered part of the row, and the UA button font replaced the editor's. - * Items are still exposed as `menuitem` and activated from the keyboard by ui-kit itself. + * 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, diff --git a/packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts b/packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts index 108565e9..330e5ef4 100644 --- a/packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts +++ b/packages/ui/src/BlockSettings/events/BlockSettingsOpenUIEvent.ts @@ -5,11 +5,9 @@ import { BlockSettingsBaseEvent } from './BlockSettingsBaseEvent.js'; */ export interface BlockSettingsOpenUIEventPayload { /** - * Position of the block to show settings for. - * - * An index rather than an id because the dispatcher is the toolbar, which follows block - * selection by position. `BlockSettingsUI` resolves it to an id once, when the menu opens, - * and the providers' items work from that id afterwards + * 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; } diff --git a/packages/ui/src/Toolbar/Toolbar.ts b/packages/ui/src/Toolbar/Toolbar.ts index e7d24499..8d7f8f38 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -148,14 +148,10 @@ export class ToolbarUI implements EditorjsPlugin { } /** - * Mounts the Block Settings widget: its button among the toolbar's own controls, and its - * menu alongside the toolbox popover. - * - * The button has to be a child of the actions container in its own right, not wrapped in - * the menu's element -- that is what puts it in `#controls` and so into the toolbar's - * roving tabindex. The tab stop is reassigned afterwards because this arrives after the - * toolbar has already rendered, and a button carrying no `tabindex` would be a second - * stop next to the plus button + * 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 */ diff --git a/packages/ui/src/controls.module.pcss b/packages/ui/src/controls.module.pcss index f0dec69b..ef0d2f58 100644 --- a/packages/ui/src/controls.module.pcss +++ b/packages/ui/src/controls.module.pcss @@ -1,11 +1,8 @@ /** - * Styling shared by the icon buttons that sit in the floating toolbar's actions container. - * - * A plain class rather than a `--toolbar-button` property set applied with `@apply`: that - * resolves within one file's PostCSS pass, so every component rendering one of these buttons - * had to repeat the declarations. The buttons are not all rendered by the same component -- - * `ToolbarUI` owns the plus button, `BlockSettingsUI` owns the settings button -- so the one - * thing they genuinely share is this appearance. + * 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; From a65fc07d7d85b2ccbe0a8266e78b97b7e737843e Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 10 Oct 2026 00:23:18 +0100 Subject: [PATCH 39/49] fix(ci): build type-only workspace dependencies before linting and testing Two packages here depend on another workspace for its *types* only, and neither the build order nor the CI jobs accounted for it. `@editorjs/default-block-settings-plugin` takes `@editorjs/ui` as a devDependency and imports from it with `import type`, so that `api.plugins['block-settings']` typechecks without a runtime coupling. The build scripts order by `--topological`, which sees dependencies but not devDependencies, and run in parallel -- so the plugin was free to build before `@editorjs/ui` had emitted anything. They now order by `--topological-dev`. `@editorjs/ui` had no way to build its workspace dependencies at all: its build is `vite build`, with none of the project references that make the tsc-built packages pull `@editorjs/sdk` along, and its `build:declaration` was empty. The new `ui.yml` workflow is what exposed this -- its lint and test jobs ran against an unbuilt `@editorjs/sdk`. It gets the `build:dependencies` script `@editorjs/editorjs` and the playground already use. Reproduced each failure locally by clearing every `dist` *and* `tsbuildinfo` -- with the latter left behind, `tsc --build` believes its output is current and emits nothing, which looks like a different bug entirely. From genuinely cold: build, lint and test all clean, 1111 tests. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- packages/editorjs/package.json | 2 +- packages/playground/package.json | 2 +- packages/plugins/default-block-settings-plugin/package.json | 3 ++- packages/ui/package.json | 5 +++-- 5 files changed, 8 insertions(+), 6 deletions(-) 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/editorjs/package.json b/packages/editorjs/package.json index 91e627f2..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", 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/package.json b/packages/plugins/default-block-settings-plugin/package.json index 5cb05afc..b9add29a 100644 --- a/packages/plugins/default-block-settings-plugin/package.json +++ b/packages/plugins/default-block-settings-plugin/package.json @@ -6,7 +6,8 @@ "types": "dist/index.d.ts", "type": "module", "scripts": { - "build": "yarn clear && tsc --build tsconfig.build.json", + "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", diff --git a/packages/ui/package.json b/packages/ui/package.json index 985ba2e4..3951887a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,8 +18,9 @@ "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:dependencies", "dev": "chokidar 'src/**/*' -c 'yarn build' --initial", "prebuild": "yarn clear", "lint": "eslint ./src", From c777990de0d0f783797a52e4188ed7f36cfe85b3 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 10 Oct 2026 01:35:31 +0100 Subject: [PATCH 40/49] fix(ui): make the block settings button reachable, dismissable and cheap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from reviewing this branch, plus the housekeeping around them. The button did nothing at all for a keyboard-only user. `BlockSelectedUIEvent` was dispatched on `mouseenter` and nowhere else, so with no pointer the plugin's target index stayed `-1` and activating a control that is reachable, named and `aria-haspopup`-annotated silently opened nothing. `BlocksUI` now also reports the block the caret moves into. Reading the editor's own caret state instead would not work: it is cleared the moment focus leaves the editable, which is exactly what reaching for a toolbar does. The button did not toggle. ui-kit closes the popover from a listener that runs before the button's own handler, so the handler saw a closed menu and re-opened it, and the menu could not be dismissed from the control that advertises it. The open state is now read on `mousedown` — the last moment the answer is still true — and tracked by the plugin rather than read back off the popover, whose shown state does not survive that click. Reading one block's tool name went through `api.document.data`, which serializes every block, every text node and every inline fragment tree, on every open of the menu. `BlocksAPI.getToolByIndex` replaces it, and the default plugin's two `document.data.blocks.length` reads become `getBlocksCount()`. Alongside: the UI package's `MenuConfig` becomes `BlockSettingsMenuConfig`, since `@editorjs/sdk` already exports a different type by that name and the two are not interchangeable; `BlockSettingsUI`, `DefaultBlockSettingsPlugin` and `ToolbarUI` drop their event bus listeners on `destroy`, the bus having outlived them; and `build:declaration` emits declarations again rather than only building dependencies. Co-Authored-By: Claude Opus 5 --- docs/events.md | 6 + docs/plugins.md | 15 +- .../src/api/BlocksAPI.integration.spec.ts | 31 +++ packages/core/src/api/BlocksAPI.ts | 9 + packages/core/src/components/BlockManager.ts | 13 ++ .../editorjs/e2e/tests/block-settings.spec.ts | 76 +++++++ .../src/index.spec.ts | 22 +- .../src/index.ts | 36 +++- packages/sdk/src/api/BlocksAPI.ts | 10 + packages/ui/package.json | 2 +- .../src/BlockSettings/BlockSettings.spec.ts | 122 ++++++++++- .../ui/src/BlockSettings/BlockSettings.ts | 127 +++++++++--- .../ui/src/Blocks/Blocks.selection.spec.ts | 189 ++++++++++++++++++ packages/ui/src/Blocks/Blocks.ts | 52 ++++- packages/ui/src/Toolbar/Toolbar.spec.ts | 28 +++ packages/ui/src/Toolbar/Toolbar.ts | 8 +- 16 files changed, 689 insertions(+), 57 deletions(-) create mode 100644 packages/ui/src/Blocks/Blocks.selection.spec.ts diff --git a/docs/events.md b/docs/events.md index 5919ac22..40282acc 100644 --- a/docs/events.md +++ b/docs/events.md @@ -47,6 +47,7 @@ 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` | @@ -59,6 +60,11 @@ together so a host can place them: `ToolbarUI` puts the button among its own con 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/plugins.md b/docs/plugins.md index a01db8db..f8d5f4e0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -136,9 +136,15 @@ const unregister = api.plugins['block-settings']?.register(ctx => ({ 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, 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. +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 @@ -365,6 +371,9 @@ 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 | diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index a8bd5db4..fc847bc3 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -310,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' }); diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index 29afb914..9b00f59d 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -223,6 +223,15 @@ export class BlocksAPI implements BlocksApiInterface { 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 diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 5d0fdaa7..6ca5d6f0 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -436,6 +436,19 @@ export class BlocksManager { }, 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 diff --git a/packages/editorjs/e2e/tests/block-settings.spec.ts b/packages/editorjs/e2e/tests/block-settings.spec.ts index 63ea9dd3..9d3d341f 100644 --- a/packages/editorjs/e2e/tests/block-settings.spec.ts +++ b/packages/editorjs/e2e/tests/block-settings.spec.ts @@ -4,6 +4,9 @@ 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. @@ -49,6 +52,79 @@ test.describe('opening the menu', () => { // 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', () => { diff --git a/packages/plugins/default-block-settings-plugin/src/index.spec.ts b/packages/plugins/default-block-settings-plugin/src/index.spec.ts index ba7d84b6..3cd96262 100644 --- a/packages/plugins/default-block-settings-plugin/src/index.spec.ts +++ b/packages/plugins/default-block-settings-plugin/src/index.spec.ts @@ -103,15 +103,10 @@ function setup({ blockIds = ['b0', 'b1', 'b2'], withBlockSettings = true }: { const api = { blocks: { getIndexById: (id: string) => blocks.indexOf(id), + getBlocksCount: () => blocks.length, move, delete: remove, }, - document: { - get data() { - return { blocks: blocks.map(id => ({ id, - name: 'paragraph' })) }; - }, - }, // 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; @@ -121,6 +116,11 @@ function setup({ blockIds = ['b0', 'b1', 'b2'], withBlockSettings = true }: { 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; @@ -241,6 +241,16 @@ describe('DefaultBlockSettingsPlugin', () => { 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', () => { diff --git a/packages/plugins/default-block-settings-plugin/src/index.ts b/packages/plugins/default-block-settings-plugin/src/index.ts index b67e5860..189060cd 100644 --- a/packages/plugins/default-block-settings-plugin/src/index.ts +++ b/packages/plugins/default-block-settings-plugin/src/index.ts @@ -2,11 +2,12 @@ import type { BlockId, EditorAPI, EditorjsPlugin, - EditorjsPluginParams + EditorjsPluginParams, + EventBus } from '@editorjs/sdk'; import { CoreEventType, PluginType } from '@editorjs/sdk'; import { IconChevronDown, IconChevronUp, IconCross, IconTrash } from '@codexteam/icons'; -import type { BlockSettingsContext, MenuConfig } from '@editorjs/ui'; +import type { BlockSettingsContext, BlockSettingsMenuConfig } from '@editorjs/ui'; /** * Order the provider registers at, which puts its items after every other provider's. @@ -37,6 +38,11 @@ export class DefaultBlockSettingsPlugin implements EditorjsPlugin { */ readonly #api: EditorAPI; + /** + * Event bus the ready listener is attached to + */ + readonly #eventBus: EventBus; + /** * Removes the provider again, once it has been registered */ @@ -48,20 +54,32 @@ export class DefaultBlockSettingsPlugin implements EditorjsPlugin { */ constructor({ api, eventBus }: EditorjsPluginParams) { this.#api = api; + this.#eventBus = eventBus; - eventBus.addEventListener(`core:${CoreEventType.Ready}`, () => { - this.#register(); - }); + this.#eventBus.addEventListener(`core:${CoreEventType.Ready}`, this.#handleReady); } /** - * Takes the provider back out of the menu + * 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. * @@ -86,8 +104,8 @@ export class DefaultBlockSettingsPlugin implements EditorjsPlugin { * Builds the items for one block * @param context - the block the menu is being built for */ - #items({ blockId, blockIndex }: BlockSettingsContext): MenuConfig { - const lastIndex = this.#api.document.data.blocks.length - 1; + #items({ blockId, blockIndex }: BlockSettingsContext): BlockSettingsMenuConfig { + const lastIndex = this.#api.blocks.getBlocksCount() - 1; return [ { @@ -152,7 +170,7 @@ export class DefaultBlockSettingsPlugin implements EditorjsPlugin { const toIndex = fromIndex + offset; - if (toIndex < 0 || toIndex > this.#api.document.data.blocks.length - 1) { + if (toIndex < 0 || toIndex > this.#api.blocks.getBlocksCount() - 1) { return; } diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 896477bd..24f9364b 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -154,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 diff --git a/packages/ui/package.json b/packages/ui/package.json index 3951887a..43803b54 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,7 +20,7 @@ "scripts": { "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:dependencies", + "build:declaration": "yarn build", "dev": "chokidar 'src/**/*' -c 'yarn build' --initial", "prebuild": "yarn clear", "lint": "eslint ./src", diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts index 85850939..ac3efa05 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.spec.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -72,14 +72,10 @@ function setup(blocks: StubBlock[] = defaultBlocks): { blocks: { getIdByIndex, getIndexById: jest.fn((id: string) => blocks.findIndex(block => block.id === id)), - }, - document: { /** * Read fresh each time, so a test can mutate the block list between opens */ - get data() { - return { blocks }; - }, + getToolByIndex: jest.fn((index: number) => blocks[index]?.name), }, plugins: {}, } as unknown as EditorAPI; @@ -149,6 +145,16 @@ 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(); +} + /** * Asks the plugin to open settings for a block, then lets the providers settle * @param eventBus - bus to dispatch on @@ -580,7 +586,7 @@ describe('BlockSettingsUI', () => { })); selectBlock(1); - instance.button.click(); + clickButton(instance.button); await flush(); expect(itemTitles(instance.element)).toEqual(['For block-1']); @@ -593,7 +599,7 @@ describe('BlockSettingsUI', () => { })); selectBlock(0); - instance.button.click(); + clickButton(instance.button); await flush(); expect(instance.button.getAttribute('aria-expanded')).toBe('true'); @@ -610,7 +616,7 @@ describe('BlockSettingsUI', () => { })); selectBlock(0); - instance.button.click(); + clickButton(instance.button); await flush(); // Safari leaves a clicked button unfocused, and the popover would then have nowhere @@ -618,6 +624,64 @@ describe('BlockSettingsUI', () => { 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}`, @@ -625,19 +689,57 @@ describe('BlockSettingsUI', () => { })); selectBlock(0); - instance.button.click(); + clickButton(instance.button); await flush(); // The menu belongs to block 0; hovering elsewhere must not move it underneath. selectBlock(2); instance.plugin.publicApi.close(); - instance.button.click(); + clickButton(instance.button); await flush(); expect(itemTitles(instance.element)).toEqual(['For block-0']); }); }); + 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 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(() => ({ diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index 1701d0a1..b3fa7cd4 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -24,12 +24,14 @@ 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: + * 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 MenuConfig = PopoverItemParams | PopoverItemParams[]; +export type BlockSettingsMenuConfig = PopoverItemParams | PopoverItemParams[]; /** * The block a provider is being asked about @@ -65,7 +67,7 @@ export interface BlockSettingsContext { */ export type BlockSettingsProvider = ( context: BlockSettingsContext -) => MenuConfig | undefined | Promise; +) => BlockSettingsMenuConfig | undefined | Promise; /** * Options a provider is registered with @@ -183,6 +185,25 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { */ #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 pointer went down on the button. + * + * The popover closes itself from the click that follows, 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 click as a fresh open. `mousedown` is the last moment + * the answer is still true + */ + #wasOpenOnPointerDown = false; + /** * Registered providers */ @@ -225,29 +246,48 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { blockSettings: this.#holder, })); - this.#eventBus.addEventListener('ui:blocks:block-selected', (event: BlockSelectedUIEvent) => { - if (this.#popover?.isShown === true) { - return; - } - - this.#selectedBlockIndex = event.detail.index; - }); - - this.#eventBus.addEventListener('ui:block-settings:open', (event: BlockSettingsOpenUIEvent) => { - void this.#open(event.detail.index); - }); + this.#eventBus.addEventListener('ui:blocks:block-selected', this.#handleBlockSelected); + this.#eventBus.addEventListener('ui:block-settings:open', this.#handleOpenRequest); } /** * 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.#popover?.destroy(); this.#popover = undefined; this.#holder.remove(); this.#button.remove(); } + /** + * Follows block selection, except while the menu is open -- re-targeting under an open menu + * would leave the button and the menu pointing at different blocks + * @param event - the selection event + */ + #handleBlockSelected = (event: BlockSelectedUIEvent): void => { + if (this.#isOpen) { + return; + } + + this.#selectedBlockIndex = event.detail.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); + }; + /** * Gives the button its menu-button semantics and wires it to the menu */ @@ -256,7 +296,15 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { this.#button.setAttribute('aria-haspopup', 'menu'); this.#button.setAttribute('aria-expanded', 'false'); + this.#button.addEventListener('mousedown', () => { + this.#wasOpenOnPointerDown = this.#isOpen; + }); + this.#button.addEventListener('click', () => { + const wasOpen = this.#wasOpenOnPointerDown; + + this.#wasOpenOnPointerDown = 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 @@ -264,6 +312,17 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { */ 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); }); } @@ -288,15 +347,37 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { } /** - * Closes the menu if it is open. `hide()` emits the popover's own closed event, - * which is what dispatches `ui:block-settings:closed` + * 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 { - if (this.#popover?.isShown === true) { - this.#popover.hide(); + if (!this.#isOpen) { + return; } + + this.#popover?.hide(); + + this.#handleClosed(); } + /** + * Records the menu as closed, once, whichever route closed it + */ + #handleClosed = (): void => { + if (!this.#isOpen) { + return; + } + + this.#isOpen = false; + + this.#button.setAttribute('aria-expanded', 'false'); + + this.#eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); + }; + /** * Builds the menu for a block and opens it, unless there is nothing to show * @param index - position the opening request named @@ -319,7 +400,7 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { const items = await this.#collectItems({ blockId, blockIndex: index, - tool: this.#api.document.data.blocks[index]?.name ?? '', + tool: this.#api.blocks.getToolByIndex(index) ?? '', }); /** @@ -343,6 +424,8 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { this.#renderPopover(items); this.#popover?.show(); + + this.#isOpen = true; this.#button.setAttribute('aria-expanded', 'true'); this.#eventBus.dispatchEvent(new BlockSettingsOpenedUIEvent({})); @@ -421,11 +504,7 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { }, }); - this.#popover.on(PopoverEvent.Closed, () => { - this.#button.setAttribute('aria-expanded', 'false'); - - this.#eventBus.dispatchEvent(new BlockSettingsClosedUIEvent({})); - }); + this.#popover.on(PopoverEvent.Closed, this.#handleClosed); this.#holder.innerHTML = ''; this.#holder.appendChild(this.#popover.getElement()); 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..338180fa 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,47 @@ export class BlocksUI implements EditorjsPlugin { */ #updateSelectedBlock(event: MouseEvent): void { const block = event.target as HTMLElement; - const index = this.#blocks.indexOf(block); + + this.#selectBlock(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 { + if (index === this.#selectedBlockIndex) { + return; + } + + this.#selectedBlockIndex = index; this.#eventBus.dispatchEvent(new BlockSelectedUIEvent({ block, @@ -278,6 +326,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.spec.ts b/packages/ui/src/Toolbar/Toolbar.spec.ts index c7b3c849..77c347b1 100644 --- a/packages/ui/src/Toolbar/Toolbar.spec.ts +++ b/packages/ui/src/Toolbar/Toolbar.spec.ts @@ -175,6 +175,34 @@ describe('ToolbarUI', () => { }); }); + 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); diff --git a/packages/ui/src/Toolbar/Toolbar.ts b/packages/ui/src/Toolbar/Toolbar.ts index 8d7f8f38..5a27aa2d 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -252,9 +252,11 @@ export class ToolbarUI implements EditorjsPlugin { * 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. @@ -262,11 +264,11 @@ export class ToolbarUI implements EditorjsPlugin { */ this.#eventBus.addEventListener(`ui:block-settings:opened`, () => { this.#isBlockSettingsOpen = true; - }); + }, { signal }); this.#eventBus.addEventListener(`ui:block-settings:closed`, () => { this.#isBlockSettingsOpen = false; - }); + }, { signal }); } /** From 0617449ace34ffac007ccc4433343f683a988521 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sat, 10 Oct 2026 02:09:16 +0100 Subject: [PATCH 41/49] docs(openspec): check off the block-settings-ui review follow-ups Co-Authored-By: Claude Opus 5 --- openspec/changes/block-settings-ui/tasks.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index 99a3c043..29631c4e 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -85,13 +85,13 @@ ## 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) From e11ac0424cfa9864231cad3aef040f1e8adee362 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sun, 11 Oct 2026 21:52:13 +0100 Subject: [PATCH 42/49] fix(ui): stop one settings provider from taking the whole menu with it its own: a provider that never settles meant the menu never opened, and never opened again either, because every later attempt waited on the same provider. Each one now races a timeout, and the expiry lands in the same skip-and-log path as a provider that threw. A close arriving while the menu is still being built was also ignored, because #isOpen is only set once the providers have returned -- so the menu appeared a moment after the caller asked for it to be gone. #close() abandons the request in flight before looking at the open state. destroy() had the same hole: dropping the bus listeners stops the next request, not the one already waiting on a holder that is about to be removed. Co-Authored-By: Claude Opus 5 --- openspec/changes/block-settings-ui/tasks.md | 6 +- .../src/BlockSettings/BlockSettings.spec.ts | 86 +++++++++++++++++++ .../ui/src/BlockSettings/BlockSettings.ts | 53 +++++++++++- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index 29631c4e..4a2f2026 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -95,9 +95,9 @@ ## 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) diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts index ac3efa05..54098c01 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.spec.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -37,6 +37,9 @@ 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 @@ -472,6 +475,34 @@ describe('BlockSettingsUI', () => { 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'); @@ -527,6 +558,35 @@ describe('BlockSettingsUI', () => { 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 let the newest request win when two overlap', async () => { const onOpened = jest.fn(); @@ -719,6 +779,32 @@ describe('BlockSettingsUI', () => { 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(); diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index b3fa7cd4..00a063bc 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -110,6 +110,14 @@ declare module '@editorjs/sdk' { /* 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 */ @@ -261,6 +269,12 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { this.#eventBus.removeEventListener('ui:blocks:block-selected', this.#handleBlockSelected); this.#eventBus.removeEventListener('ui:block-settings:open', this.#handleOpenRequest); + /** + * 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.#openToken++; + this.#popover?.destroy(); this.#popover = undefined; this.#holder.remove(); @@ -354,6 +368,13 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { * without emitting anything, which would leave the button advertising a menu that is gone */ #close(): void { + /** + * A menu can be mid-build rather than open, since `#isOpen` is only set once the providers + * have returned. Abandoning the request here is what keeps a menu from appearing moments + * after it was closed, and is why this runs before the open-state guard below + */ + this.#openToken++; + if (!this.#isOpen) { return; } @@ -444,7 +465,7 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { const contributions = await Promise.all( ordered.map(async ({ provider }) => { try { - const result = await provider(context); + const result = await this.#withTimeout(provider(context)); if (result === undefined) { return []; @@ -475,6 +496,36 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { }, []); } + /** + * 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 From 5ec220cc6fc819c3cf661e09c84784af31e57708 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 16 Oct 2026 23:54:17 +0100 Subject: [PATCH 43/49] fix(ui): keep the settings menu pointing at the block it was built for Four ways the menu and the block it acts on could come apart. A build in flight was only abandoned by #close(). Escape and a click outside reach #handleClosed straight from the popover, so a menu dismissed while a replacement was being built came back as soon as that replacement's provider returned. Both close paths now abandon it, through one helper that destroy() uses too. Selection was still followed while a build was in flight, because #isOpen is only set once the providers return. The build for the block the user asked about would then finish and put its actions beside whichever block the pointer had moved to. A build remembers which block it is for and is abandoned when the selection leaves it. The toggle read a state captured on mousedown, which a keyboard activation never sets: Enter and Space send a click with no pointer events at all. Their keydown is where that activation starts, so it is recorded there as well, and the click reads the live state too so the toggle no longer depends on which of the popover's dismissal and this handler runs first. The button carried no type, so an editor mounted inside a form submitted it on every click of this control. BlocksUI deduplicated pointer entry along with caret moves. `mouseenter` only fires on actually crossing into a block, so there was no repetition to suppress -- and suppressing it stranded the toolbar and the settings menu, both of which ignore selection while the menu is open: after dismissing a menu opened on another block, returning to this one told them nothing and they kept acting on the old one. Caret moves are still deduplicated, since selectionchange fires for every one of them. Co-Authored-By: Claude Opus 5 --- .../src/BlockSettings/BlockSettings.spec.ts | 130 ++++++++++++++++++ .../ui/src/BlockSettings/BlockSettings.ts | 95 ++++++++++--- packages/ui/src/Blocks/Blocks.ts | 22 ++- 3 files changed, 229 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts index 54098c01..2bd7d47e 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.spec.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -158,6 +158,17 @@ function clickButton(button: HTMLButtonElement): void { 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(); +} + /** * Asks the plugin to open settings for a block, then lets the providers settle * @param eventBus - bus to dispatch on @@ -587,6 +598,72 @@ describe('BlockSettingsUI', () => { 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(); @@ -633,6 +710,59 @@ describe('BlockSettingsUI', () => { 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); + }); + + 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'); diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index 00a063bc..778d2f3a 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -203,14 +203,15 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { #isOpen = false; /** - * Whether the menu was open when the pointer went down on the button. + * Whether the menu was open when the activation that is now in progress began. * - * The popover closes itself from the click that follows, 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 click as a fresh open. `mousedown` is the last moment - * the answer is still true + * 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 */ - #wasOpenOnPointerDown = false; + #wasOpenOnActivationStart = false; /** * Registered providers @@ -228,6 +229,14 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { */ #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 @@ -245,6 +254,11 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { 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(); @@ -273,7 +287,7 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { * 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.#openToken++; + this.#abandonPendingBuild(); this.#popover?.destroy(); this.#popover = undefined; @@ -291,7 +305,18 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { return; } - this.#selectedBlockIndex = event.detail.index; + 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; }; /** @@ -311,13 +336,30 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { this.#button.setAttribute('aria-expanded', 'false'); this.#button.addEventListener('mousedown', () => { - this.#wasOpenOnPointerDown = this.#isOpen; + 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; }); this.#button.addEventListener('click', () => { - const wasOpen = this.#wasOpenOnPointerDown; + /** + * 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.#wasOpenOnPointerDown = false; + this.#wasOpenOnActivationStart = false; /** * Safari leaves a clicked button unfocused, and the popover would then capture @@ -368,12 +410,7 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { * without emitting anything, which would leave the button advertising a menu that is gone */ #close(): void { - /** - * A menu can be mid-build rather than open, since `#isOpen` is only set once the providers - * have returned. Abandoning the request here is what keeps a menu from appearing moments - * after it was closed, and is why this runs before the open-state guard below - */ - this.#openToken++; + this.#abandonPendingBuild(); if (!this.#isOpen) { return; @@ -388,6 +425,14 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { * 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; } @@ -399,12 +444,26 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { 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); /** @@ -432,6 +491,8 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { 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 diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index 338180fa..aa1fe82c 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -262,7 +262,14 @@ export class BlocksUI implements EditorjsPlugin { #updateSelectedBlock(event: MouseEvent): void { const block = event.target as HTMLElement; - this.#selectBlock(block, 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)); } /** @@ -297,10 +304,23 @@ export class BlocksUI implements EditorjsPlugin { * @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({ From c8be3b8cf0deb41615b45353d40806957aadc896 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 16 Oct 2026 23:56:38 +0100 Subject: [PATCH 44/49] docs(openspec): check off the third-round block-settings-ui follow-ups Co-Authored-By: Claude Opus 5 --- openspec/changes/block-settings-ui/tasks.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index 4a2f2026..94ba8762 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -101,8 +101,8 @@ ## 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 From af13270b52ef09a18e3a9849a2f4ae0a1b0a708c Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 5 Nov 2026 01:19:24 +0000 Subject: [PATCH 45/49] fix(ui): keep the block settings target with the button `ToolbarUI` holds its position while the toolbox is open, because that popover renders inside it, but nothing told `BlockSettingsUI`. Hovering another block moved the target while the button stayed where it was, so the menu opened for a block it was not beside -- and `Delete` is one of the items it offers. The plugin now follows the toolbox's own events and stops tracking the selection for as long as the toolbar is holding its position, as it already did for its own open menu. Co-Authored-By: Claude Opus 5 --- .../src/BlockSettings/BlockSettings.spec.ts | 53 +++++++++++++++++++ .../ui/src/BlockSettings/BlockSettings.ts | 30 +++++++++-- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts index 2bd7d47e..8f94ac5a 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.spec.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -5,6 +5,7 @@ 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'; /** @@ -706,6 +707,20 @@ describe('BlockSettingsUI', () => { })); } + /** + * 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'); }); @@ -890,6 +905,44 @@ describe('BlockSettingsUI', () => { 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', () => { diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index 778d2f3a..ba857697 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -188,6 +188,12 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { */ #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 */ @@ -270,6 +276,8 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { 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); } /** @@ -282,6 +290,8 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { */ 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. @@ -296,12 +306,12 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { } /** - * Follows block selection, except while the menu is open -- re-targeting under an open menu - * would leave the button and the menu pointing at different blocks + * 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) { + if (this.#isOpen || this.#isToolboxOpen) { return; } @@ -327,6 +337,20 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { 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 */ From 8038f1e84dcb0a9846e365e1304297277ca97c7d Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 5 Nov 2026 01:19:45 +0000 Subject: [PATCH 46/49] fix(ui): isolate a throwing isActive to the provider that shipped it ui-kit calls `isActive` while it renders an item, which happens in `#renderPopover` -- long after the per-provider `try` in `#collectItems` has returned. One throwing callback aborted the whole popover, so every other provider's items went with it and no menu opened at all. Those callbacks are now called inside the provider's own error handling and ui-kit is handed the values they return, at any depth of nested items and on an item's confirmation state. A throw costs that provider's items, as returning nothing does. Nothing is lost by resolving early: ui-kit reads the value once per render, and the popover is rebuilt on every open. Co-Authored-By: Claude Opus 5 --- .../src/BlockSettings/BlockSettings.spec.ts | 86 +++++++++++++++++++ .../ui/src/BlockSettings/BlockSettings.ts | 46 +++++++++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts index 8f94ac5a..ffd0f0fe 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.spec.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -463,6 +463,92 @@ describe('BlockSettingsUI', () => { 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(() => ({ diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index ba857697..f3f8d569 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -9,7 +9,7 @@ import type { EventBus } from '@editorjs/sdk'; import { UiComponentType } from '@editorjs/sdk'; -import type { PopoverItemParams } from '@editorjs/ui-kit'; +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'; @@ -130,6 +130,42 @@ interface Registration { 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 @@ -556,7 +592,13 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { return []; } - return Array.isArray(result) ? result : [result]; + 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 From 4c9013d484b7c9e21e39bcfe710d4592ad88b280 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 5 Nov 2026 01:19:45 +0000 Subject: [PATCH 47/49] docs(openspec): specify and check off the fourth-round block-settings-ui follow-ups The spec change for the stack is merged, so this round's delta specs land here, with the code that implements them, rather than in a spec PR of their own. `design.md` gains the reasoning for the plugin tracking the toolbox itself, and the single-owner alternative that was rejected. Co-Authored-By: Claude Opus 5 --- openspec/changes/block-settings-ui/design.md | 8 +++-- .../specs/block-settings/spec.md | 29 +++++++++++++++++-- openspec/changes/block-settings-ui/tasks.md | 5 ++++ 3 files changed, 38 insertions(+), 4 deletions(-) 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..e381ccd4 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. 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 @@ -55,6 +55,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 +121,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 94ba8762..ea3c9450 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -106,3 +106,8 @@ - [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 From baee95e33a92868b5dec7a390a9f3677310b5988 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sun, 8 Nov 2026 13:59:46 +0000 Subject: [PATCH 48/49] fix(ui): dismiss the block settings menu from a second Enter ui-kit's flipper claims Enter for the menu it owns and prevents the default before the event reaches the button, so the browser produces no `click` and the toggle that lives on it never runs. The menu could not be dismissed from the button that opened it at all. The keydown handler already received that key and only recorded the open state. It now closes the menu when the default has been prevented, which is exactly the activation that will produce no click. The click stays the primary path, so pointer activation and opening from the keyboard behave as before, and the unit helper that dispatches a keydown and a click together keeps meaning what it did. Confirmed in Chromium and WebKit: focus stays on the button, the keydown arrives with the default already prevented in the capture phase, and no click follows. The e2e case dropped when this was first found -- on the view that the remaining half belonged upstream -- comes back with it, and fails in both engines without the fix. A second case covers a claimed key arriving while the menu is closed: it must open nothing, since a dismissal is all that keydown stands in for. The suppression itself is still ui-kit's, and is on the board as "Flipper claims Enter aimed at the button that owns the popover". If that changes, this compensation becomes dead code and should go with its tests. Co-Authored-By: Claude Opus 5 --- .../editorjs/e2e/tests/block-settings.spec.ts | 23 ++++++++ .../src/BlockSettings/BlockSettings.spec.ts | 57 +++++++++++++++++++ .../ui/src/BlockSettings/BlockSettings.ts | 8 +++ 3 files changed, 88 insertions(+) diff --git a/packages/editorjs/e2e/tests/block-settings.spec.ts b/packages/editorjs/e2e/tests/block-settings.spec.ts index 9d3d341f..9d085cb7 100644 --- a/packages/editorjs/e2e/tests/block-settings.spec.ts +++ b/packages/editorjs/e2e/tests/block-settings.spec.ts @@ -206,6 +206,29 @@ test.describe('keyboard operation', () => { 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', () => { diff --git a/packages/ui/src/BlockSettings/BlockSettings.spec.ts b/packages/ui/src/BlockSettings/BlockSettings.spec.ts index ffd0f0fe..57feae38 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.spec.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.spec.ts @@ -170,6 +170,29 @@ function activateFromKeyboard(button: HTMLButtonElement): void { 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 @@ -849,6 +872,40 @@ describe('BlockSettingsUI', () => { 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', diff --git a/packages/ui/src/BlockSettings/BlockSettings.ts b/packages/ui/src/BlockSettings/BlockSettings.ts index f3f8d569..05871d51 100644 --- a/packages/ui/src/BlockSettings/BlockSettings.ts +++ b/packages/ui/src/BlockSettings/BlockSettings.ts @@ -409,6 +409,14 @@ export class BlockSettingsUI implements EditorjsPlugin<'block-settings'> { } 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', () => { From 3a4d81aec3a5d860d31f088d53afbdbcfc6520ce Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sun, 8 Nov 2026 13:59:46 +0000 Subject: [PATCH 49/49] docs(openspec): specify and check off the fifth-round block-settings-ui follow-ups The button requirement already promised the toggle holds however the button is activated. This says what that takes when the open menu's own keyboard handling claims the key. Co-Authored-By: Claude Opus 5 --- .../block-settings-ui/specs/block-settings/spec.md | 12 +++++++++++- openspec/changes/block-settings-ui/tasks.md | 4 ++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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 e381ccd4..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 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. +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 diff --git a/openspec/changes/block-settings-ui/tasks.md b/openspec/changes/block-settings-ui/tasks.md index ea3c9450..e3587cb6 100644 --- a/openspec/changes/block-settings-ui/tasks.md +++ b/openspec/changes/block-settings-ui/tasks.md @@ -111,3 +111,7 @@ - [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