refactor(editor): history as a store extension (#12214)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **Refactor**
  - Improved history and undo/redo management across the app by introducing a dedicated history extension. Undo/redo operations now use a more focused undo manager, resulting in clearer and more consistent behavior.
- **Documentation**
  - Updated API documentation to reflect changes in history management, including revised method signatures and removal of outdated event references.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Saul-Mirone
2025-05-12 01:50:57 +00:00
parent e91e0e1812
commit 6fb7f51ea2
16 changed files with 124 additions and 78 deletions

View File

@@ -411,7 +411,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
})} })}
.yText=${this.model.props.text.yText} .yText=${this.model.props.text.yText}
.inlineEventSource=${this.topContenteditableElement ?? nothing} .inlineEventSource=${this.topContenteditableElement ?? nothing}
.undoManager=${this.store.history} .undoManager=${this.store.history.undoManager}
.attributesSchema=${this.inlineManager.getSchema()} .attributesSchema=${this.inlineManager.getSchema()}
.attributeRenderer=${this.inlineManager.getRenderer()} .attributeRenderer=${this.inlineManager.getRenderer()}
.readonly=${this.store.readonly} .readonly=${this.store.readonly}

View File

@@ -185,7 +185,7 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
<rich-text <rich-text
.yText=${this.model.props.text.yText} .yText=${this.model.props.text.yText}
.inlineEventSource=${this.topContenteditableElement ?? nothing} .inlineEventSource=${this.topContenteditableElement ?? nothing}
.undoManager=${this.store.history} .undoManager=${this.store.history.undoManager}
.attributeRenderer=${this.attributeRenderer} .attributeRenderer=${this.attributeRenderer}
.attributesSchema=${this.attributesSchema} .attributesSchema=${this.attributesSchema}
.markdownMatches=${this.inlineManager?.markdownMatches} .markdownMatches=${this.inlineManager?.markdownMatches}

View File

@@ -293,7 +293,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
<rich-text <rich-text
.yText=${this.model.props.text.yText} .yText=${this.model.props.text.yText}
.inlineEventSource=${this.topContenteditableElement ?? nothing} .inlineEventSource=${this.topContenteditableElement ?? nothing}
.undoManager=${this.store.history} .undoManager=${this.store.history.undoManager}
.attributesSchema=${this.attributesSchema} .attributesSchema=${this.attributesSchema}
.attributeRenderer=${this.attributeRenderer} .attributeRenderer=${this.attributeRenderer}
.markdownMatches=${this.inlineManager?.markdownMatches} .markdownMatches=${this.inlineManager?.markdownMatches}

View File

@@ -210,7 +210,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
> >
<rich-text <rich-text
.yText=${this._rootModel?.props.title.yText} .yText=${this._rootModel?.props.title.yText}
.undoManager=${this.doc.history} .undoManager=${this.doc.history.undoManager}
.verticalScrollContainerGetter=${() => this._viewport} .verticalScrollContainerGetter=${() => this._viewport}
.readonly=${this.doc.readonly} .readonly=${this.doc.readonly}
.enableFormat=${false} .enableFormat=${false}

View File

@@ -89,14 +89,14 @@ function notifyWithUndoActionImpl(
options.abort?.addEventListener('abort', abort); options.abort?.addEventListener('abort', abort);
const clearOnClose = () => { const clearOnClose = () => {
store.history.off('stack-item-added', addHandler); store.history.undoManager.off('stack-item-added', addHandler);
store.history.off('stack-item-popped', popHandler); store.history.undoManager.off('stack-item-popped', popHandler);
disposable.unsubscribe(); disposable.unsubscribe();
options.abort?.removeEventListener('abort', abort); options.abort?.removeEventListener('abort', abort);
}; };
const addHandler = store.history.on('stack-item-added', abort); const addHandler = store.history.undoManager.on('stack-item-added', abort);
const popHandler = store.history.on('stack-item-popped', abort); const popHandler = store.history.undoManager.on('stack-item-popped', abort);
const disposable = provider const disposable = provider
.get(EditorLifeCycleExtension) .get(EditorLifeCycleExtension)
.slots.unmounted.subscribe(() => abort()); .slots.unmounted.subscribe(() => abort());

View File

@@ -59,7 +59,7 @@ abstract class ToolbarContextBase {
} }
get history() { get history() {
return this.store.history; return this.store.history.undoManager;
} }
get view() { get view() {

View File

@@ -725,13 +725,13 @@ Check if the store can undo
#### Get Signature #### Get Signature
> **get** **history**(): `UndoManager` > **get** **history**(): `HistoryExtension`
Get the Y.UndoManager instance for current store. Get the Y.UndoManager instance for current store.
##### Returns ##### Returns
`UndoManager` `HistoryExtension`
*** ***

View File

@@ -32,14 +32,6 @@ The payload can have three types:
*** ***
### historyUpdated
> **historyUpdated**: `Subject`\<`void`\>
This fires when the history is updated.
***
### ready ### ready
> **ready**: `Subject`\<`void`\> > **ready**: `Subject`\<`void`\>

View File

@@ -0,0 +1,79 @@
import { signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import type { Store } from '../../model';
import { StoreExtension } from '../store-extension';
export class HistoryExtension extends StoreExtension {
static override readonly key = 'history';
private readonly _history!: Y.UndoManager;
private readonly _canRedo = signal(false);
private readonly _canUndo = signal(false);
readonly onUpdated = new Subject<void>();
constructor(store: Store) {
super(store);
this._history = new Y.UndoManager([this.store.doc.yBlocks], {
trackedOrigins: new Set([this.store.doc.spaceDoc.clientID]),
});
}
private readonly _updateCanUndoRedoSignals = () => {
const canRedo = this._history.canRedo();
const canUndo = this._history.canUndo();
if (this._canRedo.peek() !== canRedo) {
this._canRedo.value = canRedo;
}
if (this._canUndo.peek() !== canUndo) {
this._canUndo.value = canUndo;
}
};
get canRedo() {
return this._canRedo.peek();
}
get canUndo() {
return this._canUndo.peek();
}
get canRedo$() {
return this._canRedo;
}
get canUndo$() {
return this._canUndo;
}
get undoManager() {
return this._history;
}
override loaded() {
this._updateCanUndoRedoSignals();
this._history.on('stack-cleared', this._historyObserver);
this._history.on('stack-item-added', this._historyObserver);
this._history.on('stack-item-popped', this._historyObserver);
this._history.on('stack-item-updated', this._historyObserver);
}
private readonly _historyObserver = () => {
this._updateCanUndoRedoSignals();
this.onUpdated.next();
};
override disposed() {
super.disposed();
this._history.off('stack-cleared', this._historyObserver);
this._history.off('stack-item-added', this._historyObserver);
this._history.off('stack-item-popped', this._historyObserver);
this._history.off('stack-item-updated', this._historyObserver);
this.onUpdated.complete();
}
}

View File

@@ -0,0 +1 @@
export * from './history-extension';

View File

@@ -1,4 +1,5 @@
export * from './extension'; export * from './extension';
export * from './history';
export * from './schema'; export * from './schema';
export * from './selection'; export * from './selection';
export * from './store-extension'; export * from './store-extension';

View File

@@ -94,8 +94,14 @@ export class StoreSelectionExtension extends StoreExtension {
} }
); );
this.store.history.on('stack-item-added', this._itemAdded); this.store.history.undoManager.on('stack-item-added', this._itemAdded);
this.store.history.on('stack-item-popped', this._itemPopped); this.store.history.undoManager.on('stack-item-popped', this._itemPopped);
}
override disposed() {
super.disposed();
this.store.history.undoManager.off('stack-item-added', this._itemAdded);
this.store.history.undoManager.off('stack-item-popped', this._itemPopped);
} }
get value() { get value() {

View File

@@ -9,6 +9,7 @@ import type { ExtensionType } from '../../extension/extension.js';
import { import {
BlockSchemaIdentifier, BlockSchemaIdentifier,
type Doc, type Doc,
HistoryExtension,
StoreExtensionIdentifier, StoreExtensionIdentifier,
StoreSelectionExtension, StoreSelectionExtension,
} from '../../extension/index.js'; } from '../../extension/index.js';
@@ -163,10 +164,6 @@ export type StoreSlots = {
* *
*/ */
blockUpdated: Subject<StoreBlockUpdatedPayloads>; blockUpdated: Subject<StoreBlockUpdatedPayloads>;
/**
* This fires when the history is updated.
*/
historyUpdated: Subject<void>;
/** @internal */ /** @internal */
yBlockUpdated: Subject< yBlockUpdated: Subject<
| { | {
@@ -182,7 +179,7 @@ export type StoreSlots = {
>; >;
}; };
const internalExtensions = [StoreSelectionExtension]; const internalExtensions = [StoreSelectionExtension, HistoryExtension];
/** /**
* Core store class that manages blocks and their lifecycle in BlockSuite * Core store class that manages blocks and their lifecycle in BlockSuite
@@ -231,10 +228,6 @@ export class Store {
private readonly _schema: Schema; private readonly _schema: Schema;
private readonly _canRedo = signal(false);
private readonly _canUndo = signal(false);
/** /**
* Get the id of the store. * Get the id of the store.
* *
@@ -310,7 +303,7 @@ export class Store {
if (this.readonly) { if (this.readonly) {
return false; return false;
} }
return this._canRedo.peek(); return this._history.canRedo;
} }
/** /**
@@ -322,7 +315,7 @@ export class Store {
if (this.readonly) { if (this.readonly) {
return false; return false;
} }
return this._canUndo.peek(); return this._history.canUndo;
} }
/** /**
@@ -330,35 +323,35 @@ export class Store {
* *
* @category History * @category History
*/ */
undo() { undo = () => {
if (this.readonly) { if (this.readonly) {
console.error('cannot undo in readonly mode'); console.error('cannot undo in readonly mode');
return; return;
} }
this._history.undo(); this._history.undoManager.undo();
} };
/** /**
* Redo the last undone transaction. * Redo the last undone transaction.
* *
* @category History * @category History
*/ */
redo() { redo = () => {
if (this.readonly) { if (this.readonly) {
console.error('cannot undo in readonly mode'); console.error('cannot undo in readonly mode');
return; return;
} }
this._history.redo(); this._history.undoManager.redo();
} };
/** /**
* Reset the history of the store. * Reset the history of the store.
* *
* @category History * @category History
*/ */
resetHistory() { resetHistory = () => {
return this._history.clear(); return this._history.undoManager.clear();
} };
/** /**
* Execute a transaction. * Execute a transaction.
@@ -374,7 +367,7 @@ export class Store {
* @category History * @category History
*/ */
transact(fn: () => void, shouldTransact: boolean = this._shouldTransact) { transact(fn: () => void, shouldTransact: boolean = this._shouldTransact) {
const spaceDoc = this.doc.spaceDoc; const spaceDoc = this._doc.spaceDoc;
spaceDoc.transact( spaceDoc.transact(
() => { () => {
try { try {
@@ -425,9 +418,9 @@ export class Store {
* *
* @category History * @category History
*/ */
captureSync() { captureSync = () => {
this._history.stopCapturing(); this._history.undoManager.stopCapturing();
} };
/** /**
* Get the {@link Workspace} instance for current store. * Get the {@link Workspace} instance for current store.
@@ -555,7 +548,9 @@ export class Store {
private _isDisposed = false; private _isDisposed = false;
private readonly _history!: Y.UndoManager; private get _history() {
return this._provider.get(HistoryExtension);
}
/** /**
* @internal * @internal
@@ -568,7 +563,6 @@ export class Store {
rootAdded: new Subject(), rootAdded: new Subject(),
rootDeleted: new Subject(), rootDeleted: new Subject(),
blockUpdated: new Subject(), blockUpdated: new Subject(),
historyUpdated: new Subject(),
yBlockUpdated: new Subject(), yBlockUpdated: new Subject(),
}; };
this._schema = new Schema(); this._schema = new Schema();
@@ -609,35 +603,9 @@ export class Store {
this._onBlockAdded(id, false, true); this._onBlockAdded(id, false, true);
}); });
this._history = new Y.UndoManager([this._yBlocks], {
trackedOrigins: new Set([this.doc.spaceDoc.clientID]),
});
this._updateCanUndoRedoSignals();
this._history.on('stack-cleared', this._historyObserver);
this._history.on('stack-item-added', this._historyObserver);
this._history.on('stack-item-popped', this._historyObserver);
this._history.on('stack-item-updated', this._historyObserver);
this._subscribeToSlots(); this._subscribeToSlots();
} }
private readonly _updateCanUndoRedoSignals = () => {
const canRedo = this._history.canRedo();
const canUndo = this._history.canUndo();
if (this._canRedo.peek() !== canRedo) {
this._canRedo.value = canRedo;
}
if (this._canUndo.peek() !== canUndo) {
this._canUndo.value = canUndo;
}
};
private readonly _historyObserver = () => {
this._updateCanUndoRedoSignals();
this.slots.historyUpdated.next();
};
private readonly _subscribeToSlots = () => { private readonly _subscribeToSlots = () => {
this.disposableGroup.add( this.disposableGroup.add(
this.slots.yBlockUpdated.subscribe(({ type, id, isLocal }) => { this.slots.yBlockUpdated.subscribe(({ type, id, isLocal }) => {
@@ -1270,14 +1238,13 @@ export class Store {
this._provider.getAll(StoreExtensionIdentifier).forEach(ext => { this._provider.getAll(StoreExtensionIdentifier).forEach(ext => {
ext.disposed(); ext.disposed();
}); });
if (this.doc.ready) { if (this._doc.ready) {
this._yBlocks.unobserveDeep(this._handleYEvents); this._yBlocks.unobserveDeep(this._handleYEvents);
} }
this.slots.ready.complete(); this.slots.ready.complete();
this.slots.rootAdded.complete(); this.slots.rootAdded.complete();
this.slots.rootDeleted.complete(); this.slots.rootDeleted.complete();
this.slots.blockUpdated.complete(); this.slots.blockUpdated.complete();
this.slots.historyUpdated.complete();
this.slots.yBlockUpdated.complete(); this.slots.yBlockUpdated.complete();
this.disposableGroup.dispose(); this.disposableGroup.dispose();
this._isDisposed = true; this._isDisposed = true;

View File

@@ -746,7 +746,7 @@ export class StarterDebugMenu extends ShadowlessElement {
} }
override firstUpdated() { override firstUpdated() {
this.doc.slots.historyUpdated.subscribe(() => { this.doc.history.onUpdated.subscribe(() => {
this._canUndo = this.doc.canUndo; this._canUndo = this.doc.canUndo;
this._canRedo = this.doc.canRedo; this._canRedo = this.doc.canRedo;
}); });

View File

@@ -56,6 +56,6 @@ export function initDocFromProps(
paragraphId: paragraphBlockId, paragraphId: paragraphBlockId,
surfaceId, surfaceId,
}); });
doc.history.clear(); doc.history.undoManager.clear();
}); });
} }

View File

@@ -31,7 +31,7 @@ const renderRichText = ({
const richText = new RichText(); const richText = new RichText();
richText.yText = text; richText.yText = text;
richText.undoManager = doc.history; richText.undoManager = doc.history.undoManager;
richText.readonly = doc.readonly; richText.readonly = doc.readonly;
richText.attributesSchema = inlineManager.getSchema() as any; richText.attributesSchema = inlineManager.getSchema() as any;
richText.attributeRenderer = inlineManager.getRenderer(); richText.attributeRenderer = inlineManager.getRenderer();