Skip to content

Commit

Permalink
Standalone Editor: Port UndoPlugin (#2237)
Browse files Browse the repository at this point in the history
* Standalone Editor: CreateStandaloneEditorCore

* Standalone Editor: Port LifecyclePlugin

* fix build

* fix test

* improve

* fix test

* Standalone Editor: Support keyboard input (init step)

* Standalone Editor: Port EntityPlugin

* improve

* Add test

* improve

* port selection api

* improve

* improve

* fix build

* fix build

* fix build

* improve

* Improve

* improve

* improve

* fix test

* improve

* add test

* remove unused code

* Standalone Editor: port ImageSelection plugin

* add test

* Standalone Editor: Port UndoPlugin

* improve

* Improve

* Improve

* Add test
  • Loading branch information
JiuqingSong authored Dec 9, 2023
1 parent 4e76c8e commit 803ec96
Show file tree
Hide file tree
Showing 26 changed files with 2,071 additions and 125 deletions.
2 changes: 1 addition & 1 deletion demo/scripts/controls/ContentModelEditorMainPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ class ContentModelEditorMainPane extends MainPaneBase<ContentModelMainPaneState>
inDarkMode={this.state.isDarkMode}
getDarkColor={getDarkColor}
experimentalFeatures={this.state.initState.experimentalFeatures}
undoMetadataSnapshotService={this.snapshotPlugin.getSnapshotService()}
undoSnapshotService={this.snapshotPlugin.getSnapshotService()}
trustedHTMLHandler={trustedHTMLHandler}
zoomScale={this.state.scale}
initialContent={this.content}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { applyDefaultFormat } from './utils/applyDefaultFormat';
import { applyPendingFormat } from './utils/applyPendingFormat';
import { getObjectKeys } from 'roosterjs-content-model-dom';
import { isCharacterValue } from '../publicApi/domUtils/eventUtils';
import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/eventUtils';
import { PluginEventType } from 'roosterjs-editor-types';
import type { IEditor, PluginEvent, PluginWithState } from 'roosterjs-editor-types';
import type {
Expand All @@ -12,16 +12,6 @@ import type {

// During IME input, KeyDown event will have "Process" as key
const ProcessKey = 'Process';
const CursorMovingKeys = new Set<string>([
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
'PageUp',
'PageDown',
]);

/**
* ContentModelFormat plugins helps editor to do formatting on top of content model.
Expand Down Expand Up @@ -111,7 +101,7 @@ class ContentModelFormatPlugin implements PluginWithState<ContentModelFormatPlug
break;

case PluginEventType.KeyDown:
if (CursorMovingKeys.has(event.rawEvent.key)) {
if (isCursorMovingKey(event.rawEvent)) {
this.clearPendingFormat();
} else if (
this.hasDefaultFormat &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,10 @@ class EntityPlugin implements PluginWithState<EntityPluginState> {
editor: IStandaloneEditor & IEditor,
event?: ContentChangedEvent
) {
const cmEvent = event as ContentModelContentChangedEvent | undefined;
const modifiedEntities: ChangedEntity[] =
(event as ContentModelContentChangedEvent)?.changedEntities ??
this.getChangedEntities(editor);
cmEvent?.changedEntities ?? this.getChangedEntities(editor);
const entityStates = cmEvent?.entityStates;

modifiedEntities.forEach(entry => {
const { entity, operation, rawEvent } = entry;
Expand Down Expand Up @@ -173,6 +174,21 @@ class EntityPlugin implements PluginWithState<EntityPluginState> {
}
}
});

entityStates?.forEach(entityState => {
const { id, state } = entityState;
const wrapper = this.state.entityMap[id]?.element;

if (wrapper) {
this.triggerEvent(
editor,
wrapper,
'updateEntityState',
undefined /*rawEvent*/,
state
);
}
});
}

private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] {
Expand Down Expand Up @@ -232,7 +248,8 @@ class EntityPlugin implements PluginWithState<EntityPluginState> {
editor: IEditor & IStandaloneEditor,
wrapper: HTMLElement,
operation: EntityOperation,
rawEvent?: Event
rawEvent?: Event,
state?: string
) {
const format: ContentModelEntityFormat = {};
wrapper.classList.forEach(name => {
Expand All @@ -249,6 +266,7 @@ class EntityPlugin implements PluginWithState<EntityPluginState> {
isReadonly: !!format.isReadonly,
wrapper,
},
state: operation == 'updateEntityState' ? state : undefined,
})
: null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,43 @@
import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types';
import { ChangeSource } from '../constants/ChangeSource';
import { createUndoSnapshotsService } from '../editor/UndoSnapshotsServiceImpl';
import { isCursorMovingKey } from '../publicApi/domUtils/eventUtils';
import { PluginEventType } from 'roosterjs-editor-types';
import type {
IStandaloneEditor,
StandaloneEditorOptions,
UndoPluginState,
} from 'roosterjs-content-model-types';
import type {
ContentChangedEvent,
IEditor,
PluginEvent,
PluginWithState,
Snapshot,
UndoPluginState,
UndoSnapshotsService,
} from 'roosterjs-editor-types';
import {
addSnapshotV2,
canMoveCurrentSnapshot,
clearProceedingSnapshotsV2,
createSnapshots,
isCtrlOrMetaPressed,
moveCurrentSnapshot,
canUndoAutoComplete,
} from 'roosterjs-editor-dom';
import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor';

// Max stack size that cannot be exceeded. When exceeded, old undo history will be dropped
// to keep size under limit. This is kept at 10MB
const MAX_SIZE_LIMIT = 1e7;
const Backspace = 'Backspace';
const Delete = 'Delete';
const Enter = 'Enter';

/**
* Provides snapshot based undo service for Editor
*/
class UndoPlugin implements PluginWithState<UndoPluginState> {
private editor: IEditor | null = null;
private lastKeyPress: number | null = null;
private editor: (IStandaloneEditor & IEditor) | null = null;
private state: UndoPluginState;

/**
* Construct a new instance of UndoPlugin
* @param options The wrapper of the state object
*/
constructor(options: ContentModelEditorOptions) {
constructor(options: StandaloneEditorOptions) {
this.state = {
snapshotsService: options.undoMetadataSnapshotService || createUndoSnapshots(),
snapshotsService: options.undoSnapshotService || createUndoSnapshotsService(),
isRestoring: false,
hasNewContent: false,
isNested: false,
autoCompletePosition: null,
posContainer: null,
posOffset: null,
lastKeyPress: null,
};
}

Expand All @@ -57,7 +53,7 @@ class UndoPlugin implements PluginWithState<UndoPluginState> {
* @param editor Editor instance
*/
initialize(editor: IEditor): void {
this.editor = editor;
this.editor = editor as IEditor & IStandaloneEditor;
}

/**
Expand All @@ -80,10 +76,11 @@ class UndoPlugin implements PluginWithState<UndoPluginState> {
*/
willHandleEventExclusively(event: PluginEvent) {
return (
!!this.editor &&
event.eventType == PluginEventType.KeyDown &&
event.rawEvent.which == Keys.BACKSPACE &&
event.rawEvent.key == Backspace &&
!event.rawEvent.ctrlKey &&
this.canUndoAutoComplete()
this.canUndoAutoComplete(this.editor)
);
}

Expand All @@ -107,10 +104,10 @@ class UndoPlugin implements PluginWithState<UndoPluginState> {
}
break;
case PluginEventType.KeyDown:
this.onKeyDown(event.rawEvent);
this.onKeyDown(this.editor, event.rawEvent);
break;
case PluginEventType.KeyPress:
this.onKeyPress(event.rawEvent);
this.onKeyPress(this.editor, event.rawEvent);
break;
case PluginEventType.CompositionEnd:
this.clearRedoForInput();
Expand All @@ -125,64 +122,68 @@ class UndoPlugin implements PluginWithState<UndoPluginState> {
}
}

private onKeyDown(evt: KeyboardEvent): void {
private onKeyDown(editor: IStandaloneEditor, evt: KeyboardEvent): void {
// Handle backspace/delete when there is a selection to take a snapshot
// since we want the state prior to deletion restorable
// Ignore if keycombo is ALT+BACKSPACE
if ((evt.which == Keys.BACKSPACE && !evt.altKey) || evt.which == Keys.DELETE) {
if (evt.which == Keys.BACKSPACE && !evt.ctrlKey && this.canUndoAutoComplete()) {
if ((evt.key == Backspace && !evt.altKey) || evt.key == Delete) {
if (evt.key == Backspace && !evt.ctrlKey && this.canUndoAutoComplete(editor)) {
evt.preventDefault();
this.editor?.undo();
this.state.autoCompletePosition = null;
this.lastKeyPress = evt.which;
editor.undo();
this.state.posContainer = null;
this.state.posOffset = null;
this.state.lastKeyPress = evt.key;
} else if (!evt.defaultPrevented) {
const selectionRange = this.editor?.getSelectionRange();
const selection = editor.getDOMSelection();

// Add snapshot when
// 1. Something has been selected (not collapsed), or
// 2. It has a different key code from the last keyDown event (to prevent adding too many snapshot when keeping press the same key), or
// 3. Ctrl/Meta key is pressed so that a whole word will be deleted
if (
selectionRange &&
(!selectionRange.collapsed ||
this.lastKeyPress != evt.which ||
isCtrlOrMetaPressed(evt))
selection &&
(selection.type != 'range' ||
!selection.range.collapsed ||
this.state.lastKeyPress != evt.key ||
this.isCtrlOrMetaPressed(editor, evt))
) {
this.addUndoSnapshot();
}

// Since some content is deleted, always set hasNewContent to true so that we will take undo snapshot next time
this.state.hasNewContent = true;
this.lastKeyPress = evt.which;
this.state.lastKeyPress = evt.key;
}
} else if (evt.which >= Keys.PAGEUP && evt.which <= Keys.DOWN) {
} else if (isCursorMovingKey(evt)) {
// PageUp, PageDown, Home, End, Left, Right, Up, Down
if (this.state.hasNewContent) {
this.addUndoSnapshot();
}
this.lastKeyPress = 0;
} else if (this.lastKeyPress == Keys.BACKSPACE || this.lastKeyPress == Keys.DELETE) {
this.state.lastKeyPress = null;
} else if (this.state.lastKeyPress == Backspace || this.state.lastKeyPress == Delete) {
if (this.state.hasNewContent) {
this.addUndoSnapshot();
}
}
}

private onKeyPress(evt: KeyboardEvent): void {
private onKeyPress(editor: IStandaloneEditor, evt: KeyboardEvent): void {
if (evt.metaKey) {
// if metaKey is pressed, simply return since no actual effect will be taken on the editor.
// this is to prevent changing hasNewContent to true when meta + v to paste on Safari.
return;
}

const range = this.editor?.getSelectionRange();
const selection = editor.getDOMSelection();

if (
(range && !range.collapsed) ||
(evt.which == Keys.SPACE && this.lastKeyPress != Keys.SPACE) ||
evt.which == Keys.ENTER
(selection && (selection.type != 'range' || !selection.range.collapsed)) ||
(evt.key == ' ' && this.state.lastKeyPress != ' ') ||
evt.key == Enter
) {
this.addUndoSnapshot();
if (evt.which == Keys.ENTER) {

if (evt.key == Enter) {
// Treat ENTER as new content so if there is no input after ENTER and undo,
// we restore the snapshot before ENTER
this.state.hasNewContent = true;
Expand All @@ -191,18 +192,18 @@ class UndoPlugin implements PluginWithState<UndoPluginState> {
this.clearRedoForInput();
}

this.lastKeyPress = evt.which;
this.state.lastKeyPress = evt.key;
}

private onBeforeKeyboardEditing(event: KeyboardEvent) {
// For keyboard event (triggered from Content Model), we can get its keycode from event.data
// And when user is keep pressing the same key, mark editor with "hasNewContent" so that next time user
// do some other action or press a different key, we will add undo snapshot
if (event.which != this.lastKeyPress) {
if (event.key != this.state.lastKeyPress) {
this.addUndoSnapshot();
}

this.lastKeyPress = event.which;
this.state.lastKeyPress = event.key;
this.state.hasNewContent = true;
}

Expand All @@ -221,36 +222,33 @@ class UndoPlugin implements PluginWithState<UndoPluginState> {

private clearRedoForInput() {
this.state.snapshotsService.clearRedo();
this.lastKeyPress = 0;
this.state.lastKeyPress = null;
this.state.hasNewContent = true;
}

private canUndoAutoComplete() {
const focusedPosition = this.editor?.getFocusedPosition();
private canUndoAutoComplete(editor: IStandaloneEditor) {
const selection = editor.getDOMSelection();

return (
this.state.snapshotsService.canUndoAutoComplete() &&
!!focusedPosition &&
!!this.state.autoCompletePosition?.equalTo(focusedPosition)
selection?.type == 'range' &&
selection.range.collapsed &&
selection.range.startContainer == this.state.posContainer &&
selection.range.startOffset == this.state.posOffset
);
}

private addUndoSnapshot() {
this.editor?.addUndoSnapshot();
this.state.autoCompletePosition = null;
this.state.posContainer = null;
this.state.posOffset = null;
}
}

function createUndoSnapshots(): UndoSnapshotsService<Snapshot> {
const snapshots = createSnapshots<Snapshot>(MAX_SIZE_LIMIT);
private isCtrlOrMetaPressed(editor: IStandaloneEditor, event: KeyboardEvent) {
const env = editor.getEnvironment();

return {
canMove: (delta: number): boolean => canMoveCurrentSnapshot(snapshots, delta),
move: (delta: number): Snapshot | null => moveCurrentSnapshot(snapshots, delta),
addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) =>
addSnapshotV2(snapshots, snapshot, isAutoCompleteSnapshot),
clearRedo: () => clearProceedingSnapshotsV2(snapshots),
canUndoAutoComplete: () => canUndoAutoComplete(snapshots),
};
return env.isMac ? event.metaKey : event.ctrlKey;
}
}

/**
Expand All @@ -259,7 +257,7 @@ function createUndoSnapshots(): UndoSnapshotsService<Snapshot> {
* @param option The editor option
*/
export function createUndoPlugin(
option: ContentModelEditorOptions
option: StandaloneEditorOptions
): PluginWithState<UndoPluginState> {
return new UndoPlugin(option);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createDOMEventPlugin } from './DOMEventPlugin';
import { createEntityPlugin } from './EntityPlugin';
import { createLifecyclePlugin } from './LifecyclePlugin';
import { createSelectionPlugin } from './SelectionPlugin';
import { createUndoPlugin } from './UndoPlugin';
import type {
StandaloneEditorCorePlugins,
StandaloneEditorOptions,
Expand All @@ -27,5 +28,6 @@ export function createStandaloneEditorCorePlugins(
lifecycle: createLifecyclePlugin(options, contentDiv),
entity: createEntityPlugin(),
selection: createSelectionPlugin(options),
undo: createUndoPlugin(options),
};
}
Loading

0 comments on commit 803ec96

Please sign in to comment.