diff --git a/examples/layout/layout.ts b/examples/layout/layout.ts index 34da3673f..d6e7d7648 100644 --- a/examples/layout/layout.ts +++ b/examples/layout/layout.ts @@ -97,7 +97,7 @@ editor.configure(Theme, { .get(Parser) .parse( 'text/html', - `
`, + `
`, ); }, }, @@ -105,12 +105,12 @@ editor.configure(Theme, { id: 'iframe', label: 'Theme color red in iframe', render: async (editor: JWEditor): Promise => { - return editor.plugins - .get(Parser) - .parse( - 'text/html', - `
`, - ); + return editor.plugins.get(Parser).parse( + 'text/html', + ` +
+
`, + ); }, }, ], diff --git a/package.json b/package.json index d260d3a67..8a85a5d8d 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,10 @@ "dev": "webpack-dev-server --config webpack-examples.config.js", "build": "rm -rf build/examples; mkdir -p build/examples/; cp -r ./examples ./build; webpack --config webpack-examples.config.js", "build-odoo": "rm -rf build/examples; mkdir -p build/examples/; cp -r ./examples ./build; webpack --config webpack-odoo.config.js", - "perf": "karma start --include-files test/**/*.perf.ts", - "coverage": "karma start --coverage", - "debug": "karma start --no-browsers --debug", - "test": "karma start" + "perf": "karma start --include-files **/*.perf.ts", + "coverage": "karma start --coverage --include-files **/*.test.ts", + "debug": "karma start --no-browsers --debug --include-files **/*.test.ts", + "test": "karma start --include-files **/*.test.ts" }, "repository": { "type": "git", diff --git a/packages/bundle-basic-editor/BasicEditor.ts b/packages/bundle-basic-editor/BasicEditor.ts index 45242c91f..5bc6e4792 100644 --- a/packages/bundle-basic-editor/BasicEditor.ts +++ b/packages/bundle-basic-editor/BasicEditor.ts @@ -33,6 +33,7 @@ import { BackgroundColor } from '../../packages/plugin-backgroundcolor/src/Backg import { Layout } from '../../packages/plugin-layout/src/Layout'; import { DomLayout } from '../../packages/plugin-dom-layout/src/DomLayout'; import { DomEditable } from '../../packages/plugin-dom-editable/src/DomEditable'; +import { History } from '../../packages/plugin-history/src/History'; import { VNode } from '../../packages/core/src/VNodes/VNode'; import { Input } from '../../packages/plugin-input/src/Input'; import { Dialog } from '../../packages/plugin-dialog/src/Dialog'; @@ -64,6 +65,7 @@ export class BasicEditor extends JWEditor { [Html], [DomLayout], [DomEditable], + [History], [Inline], [Char], [LineBreak], @@ -146,6 +148,7 @@ export class BasicEditor extends JWEditor { ['OrderedListButton', 'UnorderedListButton', 'ChecklistButton'], ['IndentButton', 'OutdentButton'], ['LinkButton', 'UnlinkButton'], + ['UndoButton', 'RedoButton'], ], }); } diff --git a/packages/bundle-odoo-website-editor/OdooWebsiteEditor.ts b/packages/bundle-odoo-website-editor/OdooWebsiteEditor.ts index 9bbddeb0e..0d016671e 100644 --- a/packages/bundle-odoo-website-editor/OdooWebsiteEditor.ts +++ b/packages/bundle-odoo-website-editor/OdooWebsiteEditor.ts @@ -99,7 +99,10 @@ export class OdooWebsiteEditor extends JWEditor { constructor(options: OdooWebsiteEditorOptions) { super(); class CustomPlugin extends JWPlugin { - commands = options.customCommands; + commands = Object.assign( + { commit: { handler: options.afterRender } }, + options.customCommands, + ); } this.configure({ @@ -219,7 +222,6 @@ export class OdooWebsiteEditor extends JWEditor { ['editable', ['main']], ], location: options.location, - afterRender: options.afterRender, }); this.configure(DomEditable, { autoFocus: true, @@ -244,9 +246,4 @@ export class OdooWebsiteEditor extends JWEditor { const nodes = await renderer.render('dom/html', editable); return nodes && nodes[0]; } - - async render(): Promise { - const domLayout = this.plugins.get(DomLayout); - return domLayout.redraw(); - } } diff --git a/packages/bundle-odoo-website-editor/odoo-integration.ts b/packages/bundle-odoo-website-editor/odoo-integration.ts index 9af68defe..286c96317 100644 --- a/packages/bundle-odoo-website-editor/odoo-integration.ts +++ b/packages/bundle-odoo-website-editor/odoo-integration.ts @@ -1,7 +1,7 @@ import { BasicEditor } from '../bundle-basic-editor/BasicEditor'; import { DevTools } from '../plugin-devtools/src/DevTools'; import { OdooWebsiteEditor } from './OdooWebsiteEditor'; -import { VRange, withRange } from '../core/src/VRange'; +import { VRange } from '../core/src/VRange'; import { DomLayoutEngine } from '../plugin-dom-layout/src/DomLayoutEngine'; import { Layout } from '../plugin-layout/src/Layout'; import { Renderer } from '../plugin-renderer/src/Renderer'; @@ -27,7 +27,6 @@ export { DomLayoutEngine, Renderer, ImageNode, - withRange, VRange, InlineNode, LinkFormat, diff --git a/packages/core/src/Core.ts b/packages/core/src/Core.ts index da23e8ab9..0ffb51ea4 100644 --- a/packages/core/src/Core.ts +++ b/packages/core/src/Core.ts @@ -85,7 +85,7 @@ export class Core extends JWPlugin range.mode.is(range.startContainer, RuleProperty.EDITABLE) ) { // Otherwise set range start at previous valid leaf. - let ancestor = range.start.parent; + let ancestor: VNode = range.start.parent; while ( ancestor && range.mode.is(ancestor, RuleProperty.BREAKABLE) && @@ -129,7 +129,7 @@ export class Core extends JWPlugin range.mode.is(range.endContainer, RuleProperty.EDITABLE) ) { // Otherwise set range end at next valid leaf. - let ancestor = range.end.parent; + let ancestor: VNode = range.end.parent; while ( ancestor && range.mode.is(ancestor, RuleProperty.BREAKABLE) && diff --git a/packages/core/src/JWEditor.ts b/packages/core/src/JWEditor.ts index ff550bf7d..b3a193bf1 100644 --- a/packages/core/src/JWEditor.ts +++ b/packages/core/src/JWEditor.ts @@ -1,8 +1,9 @@ -import { Dispatcher } from './Dispatcher'; +import { Dispatcher, CommandParams } from './Dispatcher'; import { JWPlugin, JWPluginConfig } from './JWPlugin'; import { Core } from './Core'; import { ContextManager } from './ContextManager'; import { VSelection } from './VSelection'; +import { VRange } from './VRange'; import { isConstructor } from '../../utils/src/utils'; import { Keymap } from '../../plugin-keymap/src/Keymap'; import { StageError } from '../../utils/src/errors'; @@ -10,6 +11,10 @@ import { ContainerNode } from './VNodes/ContainerNode'; import { AtomicNode } from './VNodes/AtomicNode'; import { SeparatorNode } from './VNodes/SeparatorNode'; import { ModeIdentifier, ModeDefinition, Mode } from './Mode'; +import { Memory, ChangesLocations } from './Memory/Memory'; +import { makeVersionable } from './Memory/Versionable'; +import { VersionableArray } from './Memory/VersionableArray'; +import { Point } from './VNodes/VNode'; export enum EditorStage { CONFIGURATION = 'configuration', @@ -26,9 +31,13 @@ export type Loadables = { }; type Commands = Extract; -type CommandParams = K extends Commands +type CommandParamsType = K extends Commands ? Parameters[0] : never; +export interface CommitParams extends CommandParams { + changesLocations: ChangesLocations; + commandNames: string[]; +} export interface JWEditorConfig { /** @@ -68,7 +77,10 @@ export class JWEditor { plugins: [], loadables: {}, }; - selection = new VSelection(this); + memory: Memory; + memoryInfo: { commandNames: string[] }; + private _memoryID = 0; + selection: VSelection; loaders: Record = {}; private mutex = Promise.resolve(); // Use a set so that when asynchronous functions are called we ensure that @@ -86,6 +98,7 @@ export class JWEditor { constructor() { this.dispatcher = new Dispatcher(this); this.plugins = new Map(); + this.selection = new VSelection(this); this.contextManager = new ContextManager(this); this.nextEventMutex = this.nextEventMutex.bind(this); @@ -130,9 +143,18 @@ export class JWEditor { this.setMode(this.configuration.mode); } - for (const plugin of this.plugins.values()) { - await plugin.start(); - } + // create memory + this.memoryInfo = makeVersionable({ commandNames: [] }); + this.memory = new Memory(); + this.memory.attach(this.memoryInfo); + this.memory.create(this._memoryID.toString()); + + // Start all plugins in the first memory slice. + return this.execCommand(async () => { + for (const plugin of this.plugins.values()) { + await plugin.start(); + } + }); } //-------------------------------------------------------------------------- @@ -303,13 +325,12 @@ export class JWEditor { } } - async execBatch(callback: () => Promise): Promise { - this.preventRenders.add(callback); - await callback(); - this.preventRenders.delete(callback); - await this.dispatcher.dispatchHooks('@batch'); - } - + /** + * Execute arbitrary code in `callback`, then dispatch the commit event. + * + * @param callback + */ + async execCommand(callback: () => Promise | void): Promise; /** * Execute the given command. * @@ -318,28 +339,101 @@ export class JWEditor { */ async execCommand

= Commands

>( commandName: C, - params?: CommandParams, + params?: CommandParamsType, + ): Promise; + /** + * Execute the command or arbitrary code in `callback` in memory. + * + * TODO: create memory for each plugin who use the command then use + * squashInto(winnerSliceKey, winnerSliceKey, newMasterSliceKey) + * + * @param commandName name identifier of the command to execute or callback + * @param params arguments object of the command to execute + */ + async execCommand

= Commands

>( + commandName: C | (() => Promise | void), + params?: CommandParamsType, ): Promise { - return await this.dispatcher.dispatch(commandName, params); + const isFrozen = this.memory.isFrozen(); + + let memorySlice: string; + if (isFrozen) { + // Switch to the next memory slice (unfreeze the memory). + memorySlice = this._memoryID.toString(); + this.memory.switchTo(memorySlice); + this.memoryInfo.commandNames = new VersionableArray(); + } + + // Execute command. + if (typeof commandName === 'function') { + this.memoryInfo.commandNames.push('@custom'); + await commandName(); + } else { + this.memoryInfo.commandNames.push(commandName); + await this.dispatcher.dispatch(commandName, params); + } + + if (isFrozen) { + // Check if it's frozen for calling execCommand inside a call of + // execCommand Create the next memory slice (and freeze the + // current memory). + this._memoryID++; + const nextMemorySlice = this._memoryID.toString(); + this.memory.create(nextMemorySlice); + + // Send the commit message with a froozen memory. + const changesLocations = this.memory.getChangesLocations( + memorySlice, + this.memory.sliceKey, + ); + await this.dispatcher.dispatchHooks('@commit', { + changesLocations: changesLocations, + commandNames: this.memoryInfo.commandNames, + }); + } } /** - * Execute arbitrary code in `callback`, then dispatch the event. + * Create a temporary range corresponding to the given boundary points and + * call the given callback with the newly created range as argument. The + * range is automatically destroyed after calling the callback. + * + * @param bounds The points corresponding to the range boundaries. + * @param callback The callback to call with the newly created range. + * @param mode */ - async execCustomCommand

= Commands

>( - callback: () => Promise, + async withRange( + bounds: [Point, Point], + callback: (range: VRange) => Promise | void, + mode?: Mode, ): Promise { - await callback(); - await this.dispatcher.dispatchHooks('@custom'); + return this.execCommand(async () => { + this.memoryInfo.commandNames.push('@withRange'); + const range = new VRange(this, bounds, mode); + await callback(range); + range.remove(); + }); } /** * Stop this editor instance. */ async stop(): Promise { + if (this.memory) { + this.memory.create('stop'); + this.memory.switchTo('stop'); // Unfreeze the memory. + } for (const plugin of this.plugins.values()) { await plugin.stop(); } + if (this.memory) { + this.memory.create('stopped'); // Freeze the memory. + this.memory = null; + } + this.plugins.clear(); + this.dispatcher = new Dispatcher(this); + this.selection = new VSelection(this); + this.contextManager = new ContextManager(this); // Clear loaders. this.loaders = {}; this._stage = EditorStage.CONFIGURATION; diff --git a/packages/core/src/JWPlugin.ts b/packages/core/src/JWPlugin.ts index 1fd0ce77b..de2e1067f 100644 --- a/packages/core/src/JWPlugin.ts +++ b/packages/core/src/JWPlugin.ts @@ -33,6 +33,8 @@ export class JWPlugin { async stop(): Promise { // This is where plugins can do asynchronous work when the editor is // stopping (e.g. save on a server, close connections, etc). + this.dependencies.clear(); + this.editor = null; } } export interface JWPlugin { diff --git a/packages/core/src/Memory/Memory.ts b/packages/core/src/Memory/Memory.ts new file mode 100644 index 000000000..40eb7fccd --- /dev/null +++ b/packages/core/src/Memory/Memory.ts @@ -0,0 +1,902 @@ +import { VersionableID, VersionableParams } from './Versionable'; +import { ArrayParams } from './VersionableArray'; +import { + removedItem, + memoryProxyPramsKey, + NotVersionableError, + VersionableAllreadyVersionableError, + MemoryError, + FroozenError, +} from './const'; + +type SliceKey = string; +type VersionableMemoryID = number; +export type MemoryAllowedType = MemoryAllowedPrimitiveTypes | MemoryAllowedObjectType; +export type MemoryAllowedObjectType = LoopObject | LoopArray | LoopSet | object; +interface LoopObject { + [x: string]: MemoryAllowedType; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +type LoopArray = MemoryAllowedType[]; +// eslint-disable-next-line @typescript-eslint/no-empty-interface +type LoopSet = Set; +export type MemoryAllowedPrimitiveTypes = + | string + | number + | boolean + | VersionableID + | object + | Function + | symbol; // object must to be ProxyUniqID + +// MemoryType +// MemoryType for Object + +// Type that the memory handles in practice. This is how it is stored in memory. +export class MemoryTypeObject { + props: Record = {}; +} + +// MemoryType for Array + +// Type that the memory handles in practice. This is how it is stored in memory. +export class MemoryTypeArray extends MemoryTypeObject { + patch: Record = {}; +} + +// Output of memory given to proxy to operate +export class MemoryArrayCompiledWithPatch { + // the proxy has to differentiate between what was already there and what is + // being done in the current slice because deleting a key does not yield the + // same result if the key was already there before this slice or not (it + // would be marked as "removed" or ignored if wasn't already there.) + constructor( + // array as it appears in the slice right before the current one "array as of t-1" + public compiledValues: Record, + // very last patch at current time t + public newValues: MemoryTypeArray, + // new properties on the array at current time t + public props: Record, + ) {} +} + +// MemoryType for Set + +// Type that the memory handles in practice. This is how it is stored in memory. +export class MemoryTypeSet { + add: Set = new Set(); + delete: Set = new Set(); +} +// Output of memory given to proxy to operate (not sure it is useful to rename +// the type. semantic value of "compiled") +export type MemoryCompiledSet = Set; + +export type MemoryType = MemoryTypeObject | MemoryTypeArray | MemoryTypeSet; + +// MemoryType end + +export type MemoryCompiled = MemoryTypeObject | MemoryArrayCompiledWithPatch | MemoryCompiledSet; + +// List of memory accessors usable by versionable (we don't want to give them +// access to the true memory object !) +export interface MemoryWorker { + ID: number; + getProxy: (ID: VersionableID) => object; + getSlice: () => Record; + getSliceValue: (ID: VersionableID) => MemoryCompiled; + isFrozen: () => boolean; + markDirty: (ID: VersionableID) => void; + addSliceProxyParent: ( + ID: VersionableID, + parentID: VersionableID, + attributeName: string, + ) => boolean; + deleteSliceProxyParent: ( + ID: VersionableID, + parentID: VersionableID, + attributeName: string, + ) => boolean; + linkToMemory: (obj: MemoryAllowedType) => MemoryAllowedType; +} + +export type patches = { sliceKey: SliceKey; value: MemoryType }[]; +type proxyAttributePath = string[]; + +export interface ChangesLocations { + add: object[]; // The added versionables + move: object[]; // The versionables that changed parents + remove: object[]; // Versionables that had one or more parents and no longer have any. + update: [ + object, // The versionables that changed + ( + | string[] // The updated object keys + | number[] // the updated indexes for versionable array + | void + ), // Nothing for the versionable set + ][]; +} + +export const parentedPathSeparator = '•'; + +export function markAsDiffRoot(obj: MemoryAllowedObjectType): void { + obj[memoryProxyPramsKey].isDiffRoot = true; +} + +let memoryID = 0; +const memoryRootSliceName = ''; + +export class MemorySlice { + name: SliceKey; + parent: MemorySlice; + children: MemorySlice[] = []; + snapshotOrigin?: MemorySlice; + snapshot?: MemorySlice; + data: Record = {}; // registry of values + linkedParentOfProxy: Record = {}; + invalidCache: Record = {}; // paths that have been changed in given memory slice (when switching slice, this set is loaded in _invalidateCache) + ids: Set = new Set(); + constructor(name: SliceKey, parent?: MemorySlice) { + this.name = name; + this.parent = parent; + } + getPrevious(): MemorySlice { + return this.snapshotOrigin ? this.snapshotOrigin.parent : this.parent; + } +} + +export class Memory { + _id: number; + _sliceKey: SliceKey; // identifier or current memory slice + _slices: Record = {}; // Record + _currentSlice: MemorySlice; // Record + _proxies: Record = {}; + _rootProxies: Record = {}; + _memoryWorker: MemoryWorker; + _numberOfFlatSlices = 40; + _numberOfSlicePerSnapshot = 8; + _autoSnapshotCheck = 0; + + constructor() { + this._id = ++memoryID; + this.create(memoryRootSliceName); + this.switchTo(memoryRootSliceName); + this._memoryWorker = { + ID: this._id, + getProxy: (ID: VersionableID): object => this._proxies[ID as VersionableMemoryID], + getSlice: (): Record => this._currentSlice.data, + getSliceValue: (ID: VersionableID): MemoryCompiled => + this._getValue(this._sliceKey, ID), + isFrozen: this.isFrozen.bind(this), + // Mark as "modified" in this slice + markDirty: (ID: VersionableID): boolean => + (this._currentSlice.invalidCache[ID as VersionableMemoryID] = true), + // I am the proxy, I tell you I synchornized the value + deleteSliceProxyParent: this._deleteSliceProxyParent.bind(this), + addSliceProxyParent: this._addSliceProxyParent.bind(this), + linkToMemory: this._linkToMemory.bind(this), + }; + Object.freeze(this._memoryWorker); + } + + get sliceKey(): string { + return this._sliceKey; + } + + /** + * Create a memory slice. + * Modifications and changes (of objects bound to memory) are all recorded + * in these slices. The new slice created will be noted as being the + * continuation (or the child) of the current one. + * + * A slice with "children" is immutable. The modifications are therefore + * blocked and an error will be triggered if a code tries to modify one of + * these objects. To be able to edit again, you must destroy the "child" + * slices or change the memory slice. + * + * @param sliceKey + */ + create(sliceKey: SliceKey): this { + this._create(sliceKey, this._sliceKey); + return this; + } + /** + * Change the working memory slice (this must be created beforehand). + * + * @param sliceKey + */ + switchTo(sliceKey: SliceKey): this { + if (!(sliceKey in this._slices)) { + throw new MemoryError( + 'You must create the "' + sliceKey + '" slice before switch on it', + ); + } + if (sliceKey === this._sliceKey) { + return; + } + const invalidCache = this._aggregateInvalidCaches(this._sliceKey, sliceKey); + this._currentSlice = this._slices[sliceKey]; + this._sliceKey = sliceKey; + for (const key of invalidCache) { + const proxy = this._proxies[key]; + const params = proxy[memoryProxyPramsKey] as VersionableParams; + params.synchronize(); + } + this._autoSnapshotCheck++; + if (!(this._autoSnapshotCheck % this._numberOfSlicePerSnapshot)) { + this._autoSnapshot(); + } + return this; + } + /** + * Attach a versionable to memory. + * The versionable will then be versioned and its modifications will be + * recorded in the corresponding memory slots. + * All other versionables linked to given versionable attached to memory + * are automatically linked to memory. + * + * (Items bound to memory by this function will be noted as part of the + * root of changes @see getRoots ) + * + * @param versionable + */ + attach(versionable: MemoryAllowedObjectType): void { + const params = versionable[memoryProxyPramsKey] as VersionableParams; + if (!params) { + throw new NotVersionableError(); + } + if (params.object === versionable) { + throw new VersionableAllreadyVersionableError(); + } + if (!params.verify(versionable)) { + throw new NotVersionableError(); + } + if (this.isFrozen()) { + throw new FroozenError(); + } + if (!params.memory || params.memory !== this._memoryWorker) { + params.isDiffRoot = true; + this._linkToMemory(versionable); + } + this._rootProxies[params.ID as VersionableMemoryID] = true; + } + /** + * Returns the parents of the object. + * + * Example: p = {}; v = {point: p} axis = {origin: p} + * The parents of p are [[v, ['point']], [axis, ['origin']]] + * + * @param versionable + */ + getParents(versionable: MemoryAllowedObjectType): Map { + const pathChanges = new Map(); + const nodeID = versionable[memoryProxyPramsKey].ID; + const pathList: [VersionableID, string[]][] = [[nodeID, []]]; + while (pathList.length) { + const path = pathList.pop(); + const [nodeID, pathToNode] = path; + + const parentProxy: MemoryAllowedObjectType = this._proxies[ + nodeID as VersionableMemoryID + ]; + let paths: string[][] = pathChanges.get(parentProxy); + if (!paths) { + paths = []; + pathChanges.set(parentProxy, paths); + } + paths.push(path[1]); + + if (this._rootProxies[nodeID as VersionableMemoryID]) { + continue; + } + this._getProxyParentedPath(this._sliceKey, nodeID).forEach(path => { + const parentNodeID = path.split(parentedPathSeparator, 1)[0]; + const partPath = path.slice(parentNodeID.length + 1); + pathList.push([+parentNodeID, [partPath].concat(pathToNode)]); + }); + } + return pathChanges; + } + /** + * Return the location of the changes. + * + * @param from + * @param to + */ + getChangesLocations(from: SliceKey, to: SliceKey): ChangesLocations { + const diff: ChangesLocations = { + add: [], + move: [], + remove: [], + update: [], + }; + + const ancestorKey = this._getCommonAncestor(from, to); + const refs = this._getChangesPath(from, to, ancestorKey); + if (from === ancestorKey && from !== to) { + refs.shift(); + } + + const removeFromUpdate = new Set(); + + let previous: MemorySlice; + let ref: MemorySlice; + while ((ref = refs.pop())) { + const linkedParentOfProxy = ref.linkedParentOfProxy; + for (const ID in linkedParentOfProxy) { + const proxy = this._proxies[ID]; + if (linkedParentOfProxy[ID].length) { + if (ref.ids.has(+ID)) { + diff.add.push(proxy); + removeFromUpdate.add(proxy); + } else { + diff.move.push(proxy); + } + } else { + diff.remove.push(proxy); + removeFromUpdate.add(proxy); + } + } + + if (ref.parent === previous) { + for (const ID of previous.ids) { + const proxy = this._proxies[ID]; + diff.remove.push(proxy); + removeFromUpdate.add(proxy); + } + } + + const slice = ref.data; + Object.keys(slice).forEach(ID => { + const id = +ID; + const memoryItem = slice[id]; + const proxy = this._proxies[ID]; + if (removeFromUpdate.has(proxy)) { + return; + } else if (memoryItem instanceof MemoryTypeArray) { + const keys = Object.keys(memoryItem.props); + if (keys.length) { + diff.update.push([proxy, keys]); + } + const params = proxy[memoryProxyPramsKey] as ArrayParams; + const uniqIDs = params.uniqIDs; + const len = uniqIDs.length; + const half = len; + const indexes: number[] = []; + for (const i in memoryItem.patch) { + let index = half; + let step = index + 1; + while (step) { + const value = uniqIDs[index]; + if (value === i) { + break; + } else if (value > i) { + index -= step; + if (index < 0) { + index = 0; + } + } else { + index += step; + if (index >= len) { + index = len - 1; + } + } + step = (step / 2) | 0; + if (step === 0 && value > i && uniqIDs[index] < i) { + index++; + } + } + if (!indexes.includes(index)) { + indexes.push(index); + } + } + if (indexes.length) { + diff.update.push([proxy, indexes]); + } + } else if (memoryItem instanceof MemoryTypeSet) { + diff.update.push([proxy, null]); + } else { + const keys = Object.keys(memoryItem.props); + if (keys.length) { + diff.update.push([proxy, keys]); + } + } + }); + + previous = ref; + } + return diff; + } + /** + * Get if the current memory slice are imutable or not. + * + */ + isFrozen(): boolean { + return this._currentSlice.children.length > 0; + } + /** + * Remove a memory slice. + * The current slice cannot be the one being deleted or one of its children. + * + * @param sliceKey + */ + remove(sliceKey: SliceKey): this { + if (!(sliceKey in this._slices)) { + return this; + } + if (sliceKey === memoryRootSliceName) { + throw new MemoryError('You should not remove the original memory slice'); + } + + let ref = this._slices[this._sliceKey]; + while (ref) { + if (ref.name === sliceKey) { + throw new MemoryError('Please switch to a non-children slice before remove it'); + } + ref = ref.parent; + } + + const IDs = this._remove(sliceKey); + // check if the IDs are linked evrywere + Object.values(this._slices).forEach(reference => { + const linkedParentOfProxy = reference.linkedParentOfProxy; + IDs.forEach(ID => { + if (ID in linkedParentOfProxy) { + IDs.delete(ID); + } + }); + }); + // remove unlinked items + IDs.forEach(ID => { + delete this._proxies[ID]; + delete this._rootProxies[ID]; + }); + return this; + } + /** + * Return ancestor versionables noted as roots. + * + * There are two ways for a versionable to be root, either via the + * 'linkToMemory' method, or with the 'markAsDiffRoot' utility function. + * + * @param proxy + */ + getRoots(proxy: MemoryAllowedObjectType): Set { + const roots: Set = new Set(); + + const nodeID = proxy[memoryProxyPramsKey].ID; + const pathList: [VersionableID, string[]][] = [[nodeID, []]]; + while (pathList.length) { + const path = pathList.pop(); + const [nodeID, pathToNode] = path; + if (this._rootProxies[nodeID as VersionableMemoryID]) { + roots.add(this._proxies[nodeID as VersionableMemoryID]); + continue; + } + this._getProxyParentedPath(this._sliceKey, nodeID).forEach(path => { + const parentNodeID = path.split(parentedPathSeparator, 1)[0]; + const partPath = path.slice(parentNodeID.length + 1); + pathList.push([+parentNodeID, [partPath].concat(pathToNode)]); + }); + } + return roots; + } + /** + * Return the list of names of all previous memory slice of the given + * memory slice. + * + * @param sliceKey + * @param withoutSnapshot + */ + getPath(sliceKey: SliceKey, withoutSnapshot?: boolean): SliceKey[] { + const sliceKeys = []; + let ref = this._slices[sliceKey]; + while (ref && ref.name) { + sliceKeys.push(ref.name); + ref = withoutSnapshot ? ref.getPrevious() : ref.parent; + } + return sliceKeys; + } + /** + * Create the snapshot of different memory slices (use the path between the + * memory slice to get all changes) and merge the changes into a new + * destination slice. + * + * @param fromSliceKey + * @param unitSliceKey + * @param newSliceKey + */ + snapshot(fromSliceKey: SliceKey, unitSliceKey: SliceKey, newSliceKey: SliceKey): void { + const refs = this._slices; + const fromRref = refs[fromSliceKey]; + const untilRref = refs[unitSliceKey]; + + const newRef = this._create(newSliceKey, fromRref.parent && fromRref.parent.name); + this._squashInto(fromSliceKey, unitSliceKey, newRef.name); + + untilRref.children.forEach(child => { + child.parent = newRef; + }); + newRef.children = untilRref.children; + untilRref.children = []; + untilRref.snapshot = newRef; + newRef.snapshotOrigin = untilRref; + } + /** + * Compress all changes between two parented memory slices and remove all + * children memory slice. + * + * @param fromSliceKey + * @param unitSliceKey + */ + compress(fromSliceKey: SliceKey, unitSliceKey: SliceKey): boolean { + const refs = this._slices; + const fromRref = refs[fromSliceKey]; + const untilRref = refs[unitSliceKey]; + const toRemove = fromRref.children.slice().map(ref => ref.name); + fromRref.children = untilRref.children.splice(0); + this._squashInto(fromSliceKey, unitSliceKey, fromSliceKey); + let key: SliceKey; + while ((key = toRemove.pop())) { + this._remove(key); + } + return true; + } + + ///////////////////////////////////////////////////// + // private + ///////////////////////////////////////////////////// + + private _addSliceProxyParent( + ID: VersionableID, + parentID: VersionableID, + attributeName: string, + ): void { + const sliceKey = this._sliceKey; + const sliceLinkedParentOfProxy = this._slices[sliceKey].linkedParentOfProxy; + const path = + parentID + + (attributeName === undefined + ? memoryRootSliceName + : parentedPathSeparator + attributeName); + let parents = sliceLinkedParentOfProxy[ID as VersionableMemoryID]; + if (!parents) { + const parented = this._getProxyParentedPath(sliceKey, ID); + parents = sliceLinkedParentOfProxy[ID as VersionableMemoryID] = parented + ? parented.slice() + : []; + } + parents.push(path); + } + private _deleteSliceProxyParent( + ID: VersionableID, + parentID: VersionableID, + attributeName: string, + ): void { + const sliceKey = this._sliceKey; + const sliceLinkedParentOfProxy = this._slices[sliceKey].linkedParentOfProxy; + const path = + parentID + + (attributeName === undefined + ? memoryRootSliceName + : parentedPathSeparator + attributeName); + let parents = this._getProxyParentedPath(sliceKey, ID); + const index = parents.indexOf(path); + if (!sliceLinkedParentOfProxy[ID as VersionableMemoryID]) { + parents = sliceLinkedParentOfProxy[ID as VersionableMemoryID] = parents.slice(); + } + parents.splice(index, 1); + } + private _compiledArrayPatches(patches: patches): MemoryTypeArray { + const props = {}; + const valueBySeq = {}; + while (patches.length) { + const patch = patches.pop(); + const step = patch.value as MemoryTypeArray; + Object.assign(props, step.props); + Object.assign(valueBySeq, step.patch); + } + return { + patch: valueBySeq, + props: props, + }; + } + private _compiledSetPatches(patches: patches): MemoryCompiledSet { + const obj = new Set() as MemoryCompiledSet; + while (patches.length) { + const patch = patches.pop(); + const step = patch.value as MemoryTypeSet; + step.add.forEach((item: MemoryAllowedPrimitiveTypes) => + (obj as MemoryCompiledSet).add(item), + ); + step.delete.forEach((item: MemoryAllowedPrimitiveTypes) => + (obj as MemoryCompiledSet).delete(item), + ); + } + return obj; + } + private _compiledObjectPatches(patches: patches): MemoryTypeObject { + const obj = new MemoryTypeObject(); + const props = obj.props; + while (patches.length) { + const patch = patches.pop(); + const step = patch.value as MemoryTypeObject; + Object.assign(props, step.props); + } + Object.keys(props).forEach(key => { + if (props[key] === removedItem) { + delete props[key]; + } + }); + return obj; + } + private _create(sliceKey: SliceKey, fromSliceKey: SliceKey): MemorySlice { + const refs = this._slices; + if (refs[sliceKey]) { + throw new Error('The memory slice "' + sliceKey + '" already exists'); + } + const parent = refs[fromSliceKey]; + const ref = (refs[sliceKey] = new MemorySlice(sliceKey, parent)); + if (parent) { + parent.children.push(ref); + } + return ref; + } + private _getChangesPath( + fromSliceKey: SliceKey, + toSliceKey: SliceKey, + ancestorKey: SliceKey, + ): MemorySlice[] { + const fromPath = []; + let ref = this._slices[fromSliceKey]; + while (ref) { + fromPath.push(ref); + if (ref.name === ancestorKey) { + break; + } + ref = ref.getPrevious(); + } + + const toPath = []; + ref = this._slices[toSliceKey]; + while (ref) { + if (ref.name === ancestorKey) { + break; + } + toPath.push(ref); + ref = ref.getPrevious(); + } + toPath.reverse(); + return fromPath.concat(toPath); + } + private _getCommonAncestor(sliceKeyA: SliceKey, sliceKeyB: SliceKey): SliceKey { + const rootB = this._slices[sliceKeyB]; + let refA = this._slices[sliceKeyA]; + while (refA) { + let refB = rootB; + while (refB) { + if (refA.name === refB.name) { + return refA.name; + } + refB = refB.getPrevious(); + } + refA = refA.getPrevious(); + } + } + private _getProxyParentedPath(sliceKey: SliceKey, ID: VersionableID): proxyAttributePath { + // bubbling up magic for proxyParents + let ref = this._slices[sliceKey]; + while (ref) { + const slice = ref.linkedParentOfProxy; + const path = slice && slice[ID as VersionableMemoryID]; + if (path) { + return path; + } + ref = ref.parent; + } + return []; + } + private _getValue(sliceKey: string, ID: VersionableID): MemoryCompiled { + const patch = this._getPatches(undefined, sliceKey, ID); + if (!patch) { + return; + } + if (patch.type === 'set') { + return this._compiledSetPatches(patch.patches); + } else if (patch.type === 'array') { + return this._getValueArray(sliceKey, ID, patch.patches); + } else { + return this._compiledObjectPatches(patch.patches); + } + } + private _getValueArray(sliceKey: string, ID: VersionableID, patches: patches): MemoryCompiled { + const ref = this._slices[sliceKey]; + let owner: MemoryTypeArray; + if (ref.data[ID as VersionableMemoryID]) { + owner = patches.shift().value as MemoryTypeArray; + } + const value = this._compiledArrayPatches(patches); + return new MemoryArrayCompiledWithPatch( + value.patch, + owner || new MemoryTypeArray(), + value.props, + ); + } + private _getPatches( + fromSliceKey: SliceKey, + toSliceKey: string, + ID: VersionableID, + ): { patches: patches; type: string } { + let ref = this._slices[toSliceKey]; + let type: string; + const patches = []; + while (ref && ref.name !== fromSliceKey) { + const slice = ref.data; + const value = slice && slice[ID as VersionableMemoryID]; + if (!value) { + ref = ref.parent; + continue; + } + if (!type) { + if (value instanceof MemoryTypeArray) { + type = 'array'; + } else if (value instanceof MemoryTypeSet) { + type = 'set'; + } else { + type = 'object'; + } + } + patches.push({ + sliceKey: ref.name, + value: value, + }); + ref = ref.parent; + } + if (!type) { + return; + } + return { + patches: patches, + type: type, + }; + } + private _aggregateInvalidCaches(from: SliceKey, to: SliceKey): Set { + const invalidCache = new Set(); + if (from === to) { + return invalidCache; + } + const ancestorKey = this._getCommonAncestor(from, to); + const refs = this._getChangesPath(from, to, ancestorKey); + if (this._sliceKey === ancestorKey) { + refs.shift(); + } + while (refs.length) { + const ref = refs.pop(); + Object.keys(ref.invalidCache).forEach(key => { + // It was invalid before, it is still invalid now since it wasn't yet read + invalidCache.add(+key); + }); + } + return invalidCache; + } + private _linkToMemory(proxy: MemoryAllowedObjectType): void { + const params: VersionableParams = proxy[memoryProxyPramsKey]; + if (params.memory) { + if (params.memory !== this._memoryWorker) { + throw new MemoryError('This object is already linked to a other memory'); + } + return; + } + + const ID = params.ID as VersionableMemoryID; + params.memory = this._memoryWorker; + params.linkCallback(this._memoryWorker); + this._proxies[ID] = proxy; + + if (params.isDiffRoot) { + this._rootProxies[ID] = true; + } + + this._currentSlice.ids.add(+ID); + } + private _remove(sliceKey: SliceKey): Set { + const IDs = []; + let ref = this._slices[sliceKey]; + const index = ref.parent.children.indexOf(ref); + ref.parent.children.splice(index, 1); + + const refs = [ref]; + while ((ref = refs.pop())) { + const sliceKey = ref.name; + ref.children.forEach(ref => refs.push(ref)); + Object.keys(ref.linkedParentOfProxy).forEach(ID => IDs.push(+ID)); + delete this._slices[sliceKey]; + } + return new Set(IDs); + } + private _squashInto( + fromSliceKey: SliceKey, + unitSliceKey: SliceKey, + intoSliceKey: SliceKey, + ): void { + const refs = this._slices; + const fromRref = refs[fromSliceKey]; + const untilRref = refs[unitSliceKey]; + const intoRef = refs[intoSliceKey]; + + const references = []; + let ref = untilRref; + while (ref) { + references.push(ref); + if (ref === fromRref) { + break; + } + ref = ref.parent; + } + if (!ref) { + throw new Error('Can not merge the slices'); + } + + const intoLinkedParentOfProxy = refs[intoSliceKey].linkedParentOfProxy; + const intoInvalidCache = refs[intoSliceKey].invalidCache; + const intoSlices = intoRef.data; + + while ((ref = references.pop())) { + const LinkedParentOfProxy = ref.linkedParentOfProxy; + Object.keys(LinkedParentOfProxy).forEach(ID => { + intoLinkedParentOfProxy[ID] = LinkedParentOfProxy[ID].slice(); + }); + + Object.keys(ref.invalidCache).forEach(link => { + intoInvalidCache[link] = true; + }); + + const slice = ref.data; + Object.keys(slice).forEach(ID => { + const id = +ID; + const memoryItem = slice[id]; + if (!intoSlices[id]) { + intoSlices[id] = memoryItem; + return; + } + if (memoryItem instanceof MemoryTypeArray) { + const intoItem = intoSlices[id] as MemoryTypeArray; + Object.assign(intoItem.patch, memoryItem.patch); + Object.assign(intoItem.props, memoryItem.props); + } else if (memoryItem instanceof MemoryTypeSet) { + const intoItem = intoSlices[id] as MemoryTypeSet; + memoryItem.add.forEach(item => { + if (!intoItem.delete.has(item)) { + intoItem.add.add(item); + } else { + intoItem.delete.delete(item); + } + }); + memoryItem.delete.forEach(item => { + if (!intoItem.add.has(item)) { + intoItem.delete.add(item); + } else { + intoItem.add.delete(item); + } + }); + } else { + const intoItem = intoSlices[id] as MemoryTypeObject; + Object.assign(intoItem.props, memoryItem.props); + } + }); + } + } + private _autoSnapshot(): void { + const refs = []; + let ref = this._currentSlice; + while (ref && ref.name) { + refs.push(ref); + ref = ref.parent; + } + if (refs.length > this._numberOfFlatSlices + this._numberOfSlicePerSnapshot) { + const fromSliceKey = refs[refs.length - 1].name; + const unitSliceKey = refs[refs.length - 1 - this._numberOfSlicePerSnapshot].name; + const newSliceKey = unitSliceKey + '[snapshot from ' + fromSliceKey + ']'; + this.snapshot(fromSliceKey, unitSliceKey, newSliceKey); + } + } +} diff --git a/packages/core/src/Memory/Versionable.ts b/packages/core/src/Memory/Versionable.ts new file mode 100644 index 000000000..ab2a9ef0a --- /dev/null +++ b/packages/core/src/Memory/Versionable.ts @@ -0,0 +1,157 @@ +import { + MemoryAllowedType, + MemoryAllowedObjectType, + MemoryWorker, + MemoryTypeArray, + MemoryTypeObject, + MemoryTypeSet, +} from './Memory'; +import { _proxifyObject } from './VersionableObject'; +import { _proxifyArray } from './VersionableArray'; +import { _proxifySet } from './VersionableSet'; +import { + memoryProxyNotVersionableKey, + NotVersionableError, + VersionableAllreadyVersionableError, + memoryProxyPramsKey, +} from './const'; + +export class VersionableID extends Number {} +VersionableID.prototype[memoryProxyNotVersionableKey] = true; + +let MemoryID = 0; +export function generateVersionableID(): VersionableID { + return new VersionableID(++MemoryID); +} + +// Magic memory key that is set on the object through a symbol (inaccessible for +// others). It stores everything that is useful for the memory to handle this object. +export interface VersionableParams { + ID: VersionableID; // proxy id in memory. + memory?: MemoryWorker; // interface for versionale to access memory (since we don't want to give them the true memory object !) + proxy: MemoryAllowedObjectType; // proxy itself (what we return, what people actually manipulate) + object: MemoryAllowedObjectType; // Object that is proxified (it is synchronized with the proxy because the proxy updates it at some points ... -- not the Set !) + linkCallback: (memory: MemoryWorker) => void; // function to call when this versionable is linked to a memory + synchronize: () => void; // Synchronize the versionable object with the memory information. + verify: (obj: MemoryAllowedType) => boolean; // Verify if the given proxy matches the versionable object. + MemoryType: typeof MemoryTypeObject | typeof MemoryTypeArray | typeof MemoryTypeSet; + isDiffRoot?: true; // is it a standalone object or does it only makes sense as part of an other object's structure ? +} + +// queue of stuff to proxify +const toProxify = new Map void>>(); + +/** + * Take an object and return a versionable proxy to this object. + * + * @param object + */ +export function makeVersionable(object: T): T { + const params = object[memoryProxyPramsKey] as VersionableParams; + if (params) { + if (params.object === object) { + throw new VersionableAllreadyVersionableError(); + } + if (params && params.verify(object)) { + return object; + } + } + const proxy = _proxify(object); + toProxify.forEach((callbacks, torototo) => { + toProxify.delete(torototo); + const proxy = _proxify(torototo); + callbacks.forEach(callback => callback(proxy)); + }); + return proxy; +} +/** + * Create a proxy from a versionable with given handler. + * + * @param versionable + * @param handler + */ +export function proxifyVersionable( + versionable: T, + handler: ProxyHandler, +): T { + const newProxy = new Proxy(versionable as object, handler); + versionable[memoryProxyPramsKey].proxy = newProxy; + return newProxy as T; +} +/** + * Mark the current object as not versionable in memory. + * A non versionable object is not linked to the memory. The memory does not + * take care of the change inside this object, and this object is nerver + * immutable. + * + * @param object + */ +export function markNotVersionable(object: MemoryAllowedType): void { + object[memoryProxyNotVersionableKey] = true; +} +/** + * Throw an error if the given object is not a versionable. + * + * @param object + */ +export function _checkVersionable(object: T): void { + if ( + typeof object !== 'object' || + object === null || + object[memoryProxyNotVersionableKey] // this is set by the user + ) { + return; + } + + const params = object[memoryProxyPramsKey]; + if (params) { + if (params.object === object) { + throw new VersionableAllreadyVersionableError(); + } + if (params.verify(object)) { + // Already versioned ! (we could have inherited from the `params` of + // another, already versioned object, but we might not) + return; + } + } + + throw new NotVersionableError(); +} +// Recursive proxification is very limited because of callback depth. To +// circumvent this issue, we queue the proxification of children. +export function _stackedProxify( + customClass: T, + callback: (proxy: T) => void, +): void { + if ( + !customClass || + typeof customClass !== 'object' || + customClass[memoryProxyNotVersionableKey] + ) { + callback(customClass); + return; + } + const params = customClass[memoryProxyPramsKey]; + if (params) { + callback(params.proxy); + } + const callbacks = toProxify.get(customClass) || []; + toProxify.set(customClass, callbacks); + callbacks.push(callback); +} + +function _proxify(customClass: T): T { + const params = customClass[memoryProxyPramsKey]; + if (params && params.verify(customClass)) { + return params.proxy; + } + let proxy: T; + if (customClass instanceof Set) { + proxy = _proxifySet(customClass) as T; + } else if (customClass instanceof Array) { + proxy = _proxifyArray(customClass) as T; + } else { + proxy = _proxifyObject(customClass as Record) as T; + } + return proxy; +} diff --git a/packages/core/src/Memory/VersionableArray.ts b/packages/core/src/Memory/VersionableArray.ts new file mode 100644 index 000000000..b184bbd9f --- /dev/null +++ b/packages/core/src/Memory/VersionableArray.ts @@ -0,0 +1,566 @@ +import { + MemoryAllowedType, + MemoryAllowedPrimitiveTypes, + MemoryWorker, + MemoryType, + MemoryTypeArray, + MemoryArrayCompiledWithPatch, +} from './Memory'; +import { VersionableID, _checkVersionable, VersionableParams } from './Versionable'; +import { + removedItem, + memoryProxyPramsKey, + memoryProxyNotVersionableKey, + FroozenError, +} from './const'; +import { + _proxifyObject, + GettersSetters, + ProxyObjectHandler, + proxyObjectHandler, + keyType, +} from './VersionableObject'; + +type uniqSec = number[]; +type uniqID = string; +type uniqIDMemoryTypeValues = Record; + +const Undefined = Symbol('jabberwockMemoryUndefined'); + +export interface ArrayParams extends VersionableParams { + uniqIDs: uniqID[]; + sequences: uniqID[]; + proxy: MemoryAllowedType[]; + object: MemoryAllowedType[]; + map: Map; + syncSlice?: Record; + previousSliceValues?: uniqIDMemoryTypeValues; + proto: GettersSetters; +} + +const proxyArrayHandler = { + get(array: MemoryAllowedType[], prop: keyType, proxy: object): MemoryAllowedType { + if (typeof prop === 'symbol' || !isNaN(prop as number)) { + return array[prop]; + } + const params = array[memoryProxyPramsKey]; + if (!params.memory) { + return array[prop]; + } + switch (prop) { + case 'indexOf': + return indexOf.bind(proxy, params); + case 'includes': + return includes.bind(proxy, params); + case 'splice': + return splice.bind(proxy, params); + case 'push': + return array[prop]; + case 'unshift': + return unshift.bind(proxy, params); + case 'shift': + return shift.bind(proxy, params); + case 'pop': + return pop.bind(proxy, params); + case 'forEach': + return forEach.bind(proxy, params); + case 'map': + return map.bind(proxy, params); + case 'filter': + return filter.bind(proxy, params); + default: + return array[prop]; + } + }, + set( + proxyObject: MemoryAllowedType[], + prop: keyType, + value: MemoryAllowedType, + proxy: object, + ): boolean { + const params: ArrayParams = proxyObject[memoryProxyPramsKey]; + const array = params.object; + if ( + typeof prop === 'symbol' || + !params.memory || + (prop !== 'length' && isNaN(prop as number)) + ) { + return proxyObjectHandler.set(array, prop, value, proxy); + } + + const index = +prop; + const memory = params.memory; + if (memory.isFrozen()) { + throw new FroozenError(); + } + + const oldValue = array[prop]; + if (oldValue === value || (value === removedItem && !(prop in array))) { + // no change + return true; + } + + const slice = memory.getSlice(); + let memoryArray = slice[params.ID as number] as MemoryTypeArray; + if (!memoryArray) { + slice[params.ID as number] = memoryArray = new params.MemoryType() as MemoryTypeArray; + } + if (slice !== params.syncSlice) { + // allready sync, the current value (before update) is the previous value + params.syncSlice = slice; + params.previousSliceValues = {}; + array.forEach((value: MemoryAllowedType, index: number) => { + params.previousSliceValues[params.uniqIDs[index]] = value; + }); + } + + if (prop === 'length') { + const length = +(value as number); + for (let index = length; index < array.length; index++) { + const val = array[index]; + const oldParams = typeof val === 'object' && val[memoryProxyPramsKey]; + if (oldParams) { + memory.deleteSliceProxyParent( + oldParams.ID, + params.ID, + '´' + params.uniqIDs[index], + ); + } + const uid = params.uniqIDs[index]; + if ( + params.previousSliceValues[uid] === removedItem || + !(uid in params.previousSliceValues) + ) { + delete memoryArray.patch[uid]; + } else { + memoryArray.patch[uid] = removedItem; + } + } + array.length = length; + memory.markDirty(params.ID); // mark the cache as invalid when change the slide memory + return true; + } + + let newParams: VersionableParams; + if (value !== null && typeof value === 'object' && !value[memoryProxyNotVersionableKey]) { + _checkVersionable(value); + memory.linkToMemory(value as object); + newParams = value[memoryProxyPramsKey]; + } + + array[prop] = value; + + if (oldValue === Undefined) { + const uid = params.uniqIDs[index]; + if (newParams) { + memoryArray.patch[uid] = newParams.ID; + memory.addSliceProxyParent(newParams.ID, params.ID, '´' + uid); + } else { + memoryArray.patch[uid] = value; + } + params.map.set(value, uid); + } else { + const uniqIDs = params.uniqIDs; + const uid = uniqIDs[index]; + + // begin with remove previous + + if (uid) { + const mapUID = params.map.get(oldValue); + if (mapUID === uid) { + params.map.delete(oldValue); + const otherIndex = array.indexOf(oldValue); + if (otherIndex !== -1) { + params.map.set(oldValue, uniqIDs[otherIndex]); + } + } + if ( + params.previousSliceValues[uid] === removedItem || + !(uid in params.previousSliceValues) + ) { + delete memoryArray.patch[uid]; + } else { + memoryArray.patch[uid] = removedItem; + } + + const oldParams = + oldValue && typeof oldValue === 'object' && oldValue[memoryProxyPramsKey]; + if (oldParams) { + memory.deleteSliceProxyParent(oldParams.ID, params.ID, '´' + uid); + } + } + + if (value === removedItem) { + memory.markDirty(params.ID); // mark the cache as invalid when change the slide memory + return true; + } + + // and then we add item + + if (!uid && index > uniqIDs.length) { + // add fake undefined values (don't add undefined in array) + for (let k = uniqIDs.length; k < index; k++) { + const newUniqID = generateUid(params.sequences, uniqIDs[k - 1]); + uniqIDs.push(newUniqID); + memoryArray.patch[newUniqID] = Undefined; + } + } + + const isEnd = index >= uniqIDs.length; + const nearest = isEnd ? undefined : uniqIDs[index]; + const newUniqID = generateUid(params.sequences, nearest, isEnd); + uniqIDs[index] = newUniqID; + + if (newParams) { + memory.addSliceProxyParent(newParams.ID, params.ID, '´' + newUniqID); + memoryArray.patch[newUniqID] = newParams.ID; + } else { + memoryArray.patch[newUniqID] = value; + } + if (!params.map.has(oldValue)) { + params.map.set(value, newUniqID); + } + } + + memory.markDirty(params.ID); // mark the cache as invalid when change the slide memory + return true; + }, + deleteProperty(obj: object, prop: keyType): boolean { + // `removedItem` is a marker to notify that there was something here but + // it got removed + this.set(obj, prop, removedItem); + delete obj[prop]; + return true; + }, +} as ProxyObjectHandler; + +export function _proxifyArray(array: T[]): T[] { + const proxyObject = _proxifyObject(array); + const proxy = new Proxy(proxyObject, proxyArrayHandler) as T[]; + const params = proxyObject[memoryProxyPramsKey] as ArrayParams; + params.proxy = proxy; + params.linkCallback = linkVersionable; + params.MemoryType = MemoryTypeArray; + params.map = new Map(); + params.uniqIDs = []; + params.sequences = []; + return proxy; +} + +function unshift(params: ArrayParams, ...items: MemoryAllowedType[]): number { + for (let k = 0, len = items.length; k < len; k++) { + const item = items[k]; + params.object.unshift(Undefined); + params.uniqIDs.unshift(generateUid(params.sequences, undefined)); + this[0] = item; + } + return params.object.length; +} +function shift(params: ArrayParams): MemoryAllowedType { + const value = params.object[0]; + this['0'] = removedItem; + params.object.shift(); + params.uniqIDs.shift(); + return value; +} +function pop(params: ArrayParams): MemoryAllowedType { + const lastIndex = params.object.length - 1; + const value = params.object[lastIndex]; + this.length = lastIndex; + return value; +} +function splice( + params: ArrayParams, + index: number, + nb: number, + ...items: MemoryAllowedType[] +): MemoryAllowedType[] { + const array = params.object; + const uniqIDs = params.uniqIDs; + const len = array.length; + if (nb === undefined) { + nb = len - index; + } + const value: MemoryAllowedType[] = new (array.constructor as typeof Array)(); + if (nb > 0) { + for (let i = 0; i < nb; i++) { + value.push(array[i + index]); + } + for (let i = 0; i < nb; i++) { + this[(i + index).toString()] = removedItem; + } + array.splice(index, nb); + uniqIDs.splice(index, nb); + } + for (let key = 0, len = items.length; key < len; key++) { + const item = items[key]; + const i = key + index; + array.splice(i, 0, Undefined); + const nearest = uniqIDs[i - 1]; + uniqIDs.splice(i, 0, generateUid(params.sequences, nearest)); + this[i] = item; + } + return value; +} +function forEach( + params: ArrayParams, + callback: (value: MemoryAllowedType, index: number, array: MemoryAllowedType[]) => void, +): void { + const array = params.object; + for (let index = 0, len = array.length; index < len; index++) { + callback(array[index], index, this); + } +} +function map( + params: ArrayParams, + callback: ( + value: MemoryAllowedType, + index: number, + array: MemoryAllowedType[], + ) => MemoryAllowedType, +): MemoryAllowedType[] { + const result = []; + const array = params.object; + for (let index = 0, len = array.length; index < len; index++) { + result.push(callback(array[index], index, this)); + } + return result; +} +function filter( + params: ArrayParams, + callback: (value: MemoryAllowedType, index: number, array: MemoryAllowedType[]) => boolean, +): MemoryAllowedType[] { + const result = []; + const array = params.object; + for (let index = 0, len = array.length; index < len; index++) { + const value = array[index]; + if (callback(value, index, this)) { + result.push(value); + } + } + return result; +} +function indexOf(params: ArrayParams, item: MemoryAllowedType): number { + return params.object.indexOf(item); +} +function includes(params: ArrayParams, item: MemoryAllowedType): boolean { + return params.object.includes(item); +} +function proxifySyncArray(): { compiled: uniqIDMemoryTypeValues; sequences: uniqID[] } { + // Synchronization function + // Most methods of the "proxy" will call this synchronization function, even + // if it is not yet linked to a memory ! + const params = this as ArrayParams; + const memory = params.memory; + // empties the array + params.uniqIDs.length = 0; + params.object.length = 0; + // Clear props + const keys = Object.keys(params.object); + let key: string; + while ((key = keys.pop())) { + delete params.object[key]; + } + const rawValues = memory.getSliceValue(params.ID) as MemoryArrayCompiledWithPatch; + + if (!rawValues) { + return; + } + const values = Object.assign({}, rawValues.compiledValues, rawValues.newValues.patch); + const sequences = Object.keys(values); + sequences.sort(); + proxifySyncArrayItems(memory, sequences, values, params.object, params.uniqIDs); + const props = Object.assign({}, rawValues.props, rawValues.newValues.props); + proxifySyncArrayItems(memory, Object.keys(props), props, params.object); + + params.syncSlice = memory.getSlice() as Record; + params.previousSliceValues = rawValues.compiledValues; + params.sequences = sequences; + + params.map.clear(); + params.object.forEach((item: MemoryAllowedType, i: number) => { + params.map.set(item, params.uniqIDs[i]); + }); +} +function proxifySyncArrayItems( + memory: MemoryWorker, + keys: string[], + values: Record, + array: MemoryAllowedType[], + uniqIDs?: string[], +): void { + let index = 0; + for (let k = 0, len = keys.length; k < len; k++) { + const key = keys[k]; + let value = values[key]; + if (value === removedItem) { + continue; + } + if (value instanceof VersionableID) { + value = memory.getProxy(value); + } + if (uniqIDs) { + if (value !== Undefined) { + array[index] = value; + } + uniqIDs.push(key); + index++; + } else { + array[key] = value; + } + } +} +function linkVersionable(memory: MemoryWorker): void { + const params = this as ArrayParams; + params.memory = memory; + params.synchronize = proxifySyncArray; + const slice = memory.getSlice(); + const array = params.object; + const keys = Object.keys(array); + const len = keys.length; + const ID = params.ID; + if (len === 0) { + memory.markDirty(ID); + return; + } + const memoryArray = (slice[ID as number] = new params.MemoryType() as MemoryTypeArray); + const props = memoryArray.props; + const patch = memoryArray.patch; + const uniqIDs = params.uniqIDs; + const sequences = params.sequences; + let arrayIndex = -1; + for (let k = 0; k < len; k++) { + const key = keys[k]; + const index = +key; + const value = array[key]; + const valueParams = + value !== null && typeof value === 'object' && value[memoryProxyPramsKey]; + + if (valueParams) { + memory.linkToMemory(value as object); + } + if (isNaN(index)) { + if (valueParams) { + props[key] = valueParams.ID; + memory.addSliceProxyParent(valueParams.ID, ID, key); + } else { + props[key] = value; + } + } else { + arrayIndex++; + while (arrayIndex < index) { + const newUniqID = generateUid(sequences, undefined, true); + uniqIDs[arrayIndex] = newUniqID; + patch[newUniqID] = Undefined; + arrayIndex++; + } + const newUniqID = generateUid(sequences, undefined, true); + uniqIDs[index] = newUniqID; + if (valueParams) { + patch[newUniqID] = valueParams.ID; + memory.addSliceProxyParent(valueParams.ID, ID, '´' + newUniqID); + } else { + patch[newUniqID] = value; + } + } + } + params.map.clear(); + array.forEach((item: MemoryAllowedType, i: number) => { + if (!params.map.has(item)) { + params.map.set(item, uniqIDs[i]); + } + }); + memory.markDirty(ID); +} + +// IDs + +function allocUid(min: uniqSec, max: uniqSec): uniqSec { + const step = 4; + if (!min && !max) { + return [128]; + } + min = min || []; + max = max || []; + const res = []; + let minSeq = 0; + let maxSeq = max[0]; + for (let index = 0, len = Math.max(min.length, max.length); index < len; index++) { + minSeq = min[index] | 0; + maxSeq = index in max ? max[index] : 4096; + if (minSeq === 4095 && maxSeq === 4096) { + res.push(minSeq); + } else if (minSeq === maxSeq) { + res.push(minSeq); + } else if (minSeq === maxSeq - 1 && len > index - 1) { + res.push(minSeq); + } else { + break; + } + } + const diff = (maxSeq - minSeq) >> 1; + if (diff === 0) { + res.push(min.length ? 128 : 2048); + } else if (minSeq === 0) { + res.push(maxSeq - Math.min(diff, step)); + } else { + res.push(minSeq + Math.min(diff, step)); + } + return res; +} +function hexaToSeq(str: string): uniqSec { + const seq = []; + for (let k = 0, len = str.length; k < len; k += 3) { + seq.push(parseInt(str.slice(k, k + 3), 16)); + } + return seq; +} +function SeqToHexa(seq: uniqSec): uniqID { + let str = ''; + const len = seq.length; + for (let k = 0; k < len; k++) { + const n = seq[k]; + if (n === 0) { + str += '000'; + } else if (n < 16) { + str += '00' + n.toString(16); + } else if (n < 256) { + str += '0' + n.toString(16); + } else { + str += n.toString(16); + } + } + return str; +} +function generateUid(sortedUniqIDs: uniqID[], min: uniqID, isEnd?: boolean): uniqID { + let max: uniqID; + if (isEnd) { + min = sortedUniqIDs[sortedUniqIDs.length - 1]; + } else if (min) { + max = sortedUniqIDs[sortedUniqIDs.indexOf(min) + 1]; + } else { + max = sortedUniqIDs[0]; + } + const minSeq = min && hexaToSeq(min); + const maxSeq = max && hexaToSeq(max); + const newUniqID = SeqToHexa(allocUid(minSeq, maxSeq)); + + if (isEnd) { + sortedUniqIDs.push(newUniqID); + } else { + const sortedIndex = min ? sortedUniqIDs.indexOf(min) : -1; + if (sortedIndex === -1) { + sortedUniqIDs.unshift(newUniqID); + } else { + sortedUniqIDs.splice(sortedIndex + 1, 0, newUniqID); + } + } + return newUniqID; +} + +export class VersionableArray extends Array { + constructor(...items: T[]) { + super(...items); + return _proxifyArray(this); + } +} diff --git a/packages/core/src/Memory/VersionableObject.ts b/packages/core/src/Memory/VersionableObject.ts new file mode 100644 index 000000000..bb8594420 --- /dev/null +++ b/packages/core/src/Memory/VersionableObject.ts @@ -0,0 +1,278 @@ +import { + MemoryAllowedType, + MemoryAllowedPrimitiveTypes, + MemoryTypeObject, + MemoryWorker, +} from './Memory'; +import { + VersionableID, + _checkVersionable, + VersionableParams, + generateVersionableID, + _stackedProxify, +} from './Versionable'; + +import { + removedItem, + memoryProxyNotVersionableKey, + memoryProxyPramsKey, + FroozenError, + symbolVerify, +} from './const'; + +export type keyType = string | number | symbol; + +interface ObjectParams extends VersionableParams { + proxy: Record; + object: Record; + proto: GettersSetters; +} + +export interface GettersSetters { + getters: Record MemoryAllowedType>; + setters: Record void>; +} + +const classGettersSetters: WeakMap = new WeakMap(); +const rootPrototype = Object.getPrototypeOf({}); +export function getPrototypeGettersSetters(obj: object): GettersSetters { + let gettersSetters = classGettersSetters.get(obj.constructor); + if (gettersSetters) { + return gettersSetters; + } + gettersSetters = { + getters: {}, + setters: {}, + }; + classGettersSetters.set(obj.constructor, gettersSetters); + obj = Object.getPrototypeOf(obj); + do { + const descs = Object.getOwnPropertyDescriptors(obj); + Object.keys(descs).forEach(propName => { + const desc = descs[propName]; + if (!gettersSetters.getters[propName] && desc.get) { + gettersSetters.getters[propName] = desc.get as () => MemoryAllowedType; + } + if (!gettersSetters.setters[propName] && desc.set) { + gettersSetters.setters[propName] = desc.set as (value: MemoryAllowedType) => void; + } + }); + } while ((obj = Object.getPrototypeOf(obj)) && obj !== rootPrototype); + return gettersSetters; +} + +export interface ProxyObjectHandler { + get(obj: MemoryAllowedType, prop: keyType, proxy: object): MemoryAllowedType; + set(obj: MemoryAllowedType, prop: keyType, value: MemoryAllowedType, proxy: object): boolean; + has(obj: MemoryAllowedType, prop: keyType): boolean; + ownKeys(obj: MemoryAllowedType): keyType[]; + getOwnPropertyDescriptor(obj: MemoryAllowedType, prop: string | symbol): PropertyDescriptor; + deleteProperty(obj: MemoryAllowedType, prop: string): boolean; +} + +export const proxyObjectHandler = { + set(obj: object, prop: keyType, value: MemoryAllowedType, proxy: object): boolean { + // Object.assign might try to set the value of the paramsKey. We + // obviously don't want that. Let it think it succeeded (returning false + // will throw an error, which is not what we want here.) + if (prop === memoryProxyPramsKey) { + return true; + } + if (prop === symbolVerify) { + obj[symbolVerify] = value; + return true; + } + + const params = obj[memoryProxyPramsKey] as ObjectParams; + const memory = params.memory; + + if (!memory && value === removedItem) { + // "synchronize" the delete + delete obj[prop]; + return true; + } + + // if the property is a method of the class or prototype. + const protoMethod = params.proto.setters[prop as string]; + if (protoMethod) { + protoMethod.call(proxy, value as MemoryAllowedPrimitiveTypes); + return true; + } + + // if the property is a getter + const desc = Object.getOwnPropertyDescriptor(obj, prop); + if (desc?.set) { + desc.set.call(proxy, value as MemoryAllowedPrimitiveTypes); + return true; + } + + _checkVersionable(value); + + // if not linked to memory + if (!memory) { + obj[prop] = value as MemoryAllowedPrimitiveTypes; + return true; + } + + if (memory.isFrozen()) { + throw new FroozenError(); + } + + const oldValue = obj[prop]; + // The value is the same, or we are deleting a value that was not + // already there in the first place. + if (oldValue === value || (value === removedItem && !(prop in obj))) { + return true; + } + + const slice = memory.getSlice(); + let memoryObject = slice[params.ID as number] as MemoryTypeObject; + if (!memoryObject) { + slice[params.ID as number] = memoryObject = new params.MemoryType() as MemoryTypeObject; + } + const memoryObjectProps = memoryObject.props; + + let memoryItem = value; + if (value !== null && typeof value === 'object' && !value[memoryProxyNotVersionableKey]) { + // if object, the stored value needs to be "converted" (see Set) + memory.linkToMemory(value as object); + const newParams = value && value[memoryProxyPramsKey]; + memoryItem = newParams.ID; + memory.addSliceProxyParent(newParams.ID, params.ID, prop as string); + } + + // if the old value was a versionable as well, sever its link to its parent + const oldParams = oldValue && typeof oldValue === 'object' && oldValue[memoryProxyPramsKey]; + if (oldParams) { + memory.deleteSliceProxyParent(oldParams.ID, params.ID, prop as string); + } + + if (value === removedItem) { + memoryObjectProps[prop as string] = removedItem; // notify that the deletion happened in this slice + delete obj[prop]; + } else { + memoryObjectProps[prop as string] = memoryItem; + obj[prop] = value; + } + + memory.markDirty(params.ID); // mark the cache as invalid when change the slide memory + return true; + }, + deleteProperty(obj: object, prop: keyType): boolean { + // `removedItem` is a marker to notify that there was something here but + // it got removed + this.set(obj, prop, removedItem); + delete obj[prop]; + return true; + }, +} as ProxyObjectHandler; + +export function _proxifyObject(obj: object): object { + const proxy = new Proxy(obj, proxyObjectHandler) as object; + + obj[memoryProxyPramsKey] = { + ID: generateVersionableID(), + linkCallback: linkVersionable, + synchronize: proxifySyncObject, + MemoryType: MemoryTypeObject, + verify: verify, + proxy: proxy, + object: obj, + proto: getPrototypeGettersSetters(obj), + } as ObjectParams; + + const keys = Object.keys(obj); + for (let k = 0, len = keys.length; k < len; k++) { + const key = keys[k]; + const value = obj[key]; + _stackedProxify(value, newValue => { + if (newValue !== value) { + obj[key] = newValue; + } + }); + } + return proxy; +} + +function verify(proxy: MemoryAllowedType): boolean { + const params = this as ObjectParams; + const obj = params.object as object; + proxy[symbolVerify] = true; + const value = obj[symbolVerify] as boolean; + obj[symbolVerify] = false; + return value; +} +export function proxifySyncObject(): void { + // Synchronization function + // Most methods of the "proxy" will call this synchronization function, even + // if it is not yet linked to a memory ! + const params = this as ObjectParams; + const memory = params.memory; + const memoryObject = memory.getSliceValue(params.ID) as MemoryTypeObject; + const memoryObjectProps = (memoryObject && memoryObject.props) || {}; + + // Clear keys that do not exist anymore + let keys = Object.keys(params.object); + let key: string; + while ((key = keys.pop())) { + // if the object is not present in this slice or it does not have this key + if (!(key in memoryObjectProps)) { + delete params.object[key]; + } + } + + if (!memoryObject) { + return; + } + + // Update values according to what is stored + keys = Object.keys(memoryObjectProps); + while ((key = keys.pop())) { + let value = memoryObjectProps[key]; + if (value instanceof VersionableID) { + // Convert proxy references to actual proxy + value = memory.getProxy(value); + } + params.object[key] = value; + } +} +function linkVersionable(memory: MemoryWorker): void { + const params = this as ObjectParams; + params.memory = memory; + const slice = params.memory.getSlice(); + const obj = params.object; + const ID = params.ID; + const keys = Object.keys(obj); + if (keys.length) { + const memoryObjectProps = Object.assign({}, obj); + let key: string; + while ((key = keys.pop())) { + const value = obj[key]; + // Some of the values in the original object may be versionable and + // need to be converted + const valueParams = + value !== null && typeof value === 'object' && value[memoryProxyPramsKey]; + if (valueParams) { + memory.linkToMemory(value as object); + memory.addSliceProxyParent(valueParams.ID, ID, key); + memoryObjectProps[key] = valueParams.ID; + } + } + const memoryObject = new params.MemoryType() as MemoryTypeObject; + memoryObject.props = memoryObjectProps; + slice[ID as number] = memoryObject; // store the "pure" value in memory + } + memory.markDirty(ID); +} +export class VersionableObject { + constructor(obj?: Record | object) { + if (obj) { + const keys = Object.keys(obj); + let key: string; + while ((key = keys.pop())) { + this[key] = obj[key]; + } + } + return _proxifyObject(this); + } +} diff --git a/packages/core/src/Memory/VersionableSet.ts b/packages/core/src/Memory/VersionableSet.ts new file mode 100644 index 000000000..1484e7284 --- /dev/null +++ b/packages/core/src/Memory/VersionableSet.ts @@ -0,0 +1,293 @@ +import { + MemoryAllowedType, + MemoryAllowedPrimitiveTypes, + MemoryWorker, + MemoryTypeSet, + MemoryCompiledSet, +} from './Memory'; +import { + VersionableID, + _checkVersionable, + VersionableParams, + generateVersionableID, + _stackedProxify, +} from './Versionable'; + +import { memoryProxyNotVersionableKey, memoryProxyPramsKey, FroozenError } from './const'; + +interface SetParams extends VersionableParams { + size: number; + object: Set; + proxy: VersionableSet; +} + +// People can override the set methods. They will be called from the proxy, but +// sometimes we want to call the true original methods, not the override of the +// user. This is how we do it. +const genericSet = new Set() as Set; +const genericSetPrototype = Set.prototype; + +function setPrototype( + proxy: VersionableSet, + obj: Set, +): void { + do { + // This function loops on the prototypes of the object. This is what + // stops it. + // TODO refactor: while !== genericSetPrototype + if (obj === genericSetPrototype) { + break; + } + const op = Object.getOwnPropertyNames(obj); + for (let i = 0; i < op.length; i++) { + const prop = op[i]; // propName + if (!proxy[prop]) { + proxy[prop] = obj[prop]; + } + } + } while ((obj = Object.getPrototypeOf(obj))); +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +function nothing(): void {} + +export class VersionableSet extends Set { + [memoryProxyPramsKey]: SetParams; + constructor(params?: Set | T[]) { + super(); + let set: Set; // original set (won't be synced, it's just for its method ex: overrides) + let size = 0; + if (!params) { + set = genericSet as Set; + } else if (params instanceof Array) { + set = genericSet as Set; + params.forEach(value => { + size++; + _stackedProxify(value, newValue => { + set.add.call(this, newValue); + }); + }); + } else { + if (params instanceof VersionableSet) { + set = params[memoryProxyPramsKey].object as Set; + } else { + set = params as Set; + } + params.forEach(value => { + size++; + _stackedProxify(value, newValue => { + set.add.call(this, newValue); + }); + }); + setPrototype(this, set); + } + + this[memoryProxyPramsKey] = { + ID: generateVersionableID(), + linkCallback: linkVersionable, + synchronize: nothing, + MemoryType: MemoryTypeSet, + verify: (proxy: MemoryAllowedType): boolean => proxy === this, + size: size, + object: set, + proxy: this, + }; + } + + add(item: T): this { + // For Set specifically, this line will never actually *proxify* per se. + // It will either work if the item is already proxified, or throw an + // error if it is not. + _checkVersionable(item); + + const params = this[memoryProxyPramsKey]; + const memory = params.memory; + if (memory && memory.isFrozen()) { + throw new FroozenError(); + } + + const check = this.has(item); + if (!check) { + params.object.add.call(this, item); + } + + if (check || !memory) { + // Nothing changed. Either the item was already there, or we don't + // care because we are not linked to memory. + return this; + } + + let memoryItem = item as MemoryAllowedPrimitiveTypes; + if (item !== null && typeof item === 'object' && !item[memoryProxyNotVersionableKey]) { + // The item is versionable, great, but it is not versioned yet ! + // This call versions it into the memory. + memory.linkToMemory(item as object); + const itemParams = item[memoryProxyPramsKey]; + memoryItem = itemParams.ID; + memory.addSliceProxyParent(itemParams.ID, params.ID, undefined); + } + + // Get current slice. + const slice = memory.getSlice(); + let memorySet = slice[params.ID as number] as MemoryTypeSet; // read the pure value stored in memory + if (!memorySet) { + slice[params.ID as number] = memorySet = new MemoryTypeSet(); + // Mark the set as being modified in this slice (not necesarilly "dirty") + memory.markDirty(params.ID); // mark the cache as invalid when change the slide memory + } + // Update the stored changes for this slice + memorySet.add.add(memoryItem); + memorySet.delete.delete(memoryItem); + + return this; + } + delete(item: T): boolean { + const params = this[memoryProxyPramsKey]; + const memory = params.memory; + if (memory && memory.isFrozen()) { + throw new FroozenError(); + } + + const check = this.has(item); + if (check) { + params.object.delete.call(this, item); + } + + if (!check || !memory) { + return check; + } + + let memoryItem = item as MemoryAllowedPrimitiveTypes; + const itemParams = item && typeof item === 'object' && item[memoryProxyPramsKey]; + if (itemParams) { + memoryItem = itemParams.ID as VersionableID; + memory.deleteSliceProxyParent(itemParams.ID, params.ID, undefined); + } + + const slice = memory.getSlice(); + let memorySet = slice[params.ID as number] as MemoryTypeSet; + if (!memorySet) { + slice[params.ID as number] = memorySet = new MemoryTypeSet(); + } + memorySet.delete.add(memoryItem); + memorySet.add.delete(memoryItem); + + memory.markDirty(params.ID); // mark the cache as invalid when change the slide memory + + return check; + } + clear(): this { + const params = this[memoryProxyPramsKey]; + const memory = params.memory; + if (memory && memory.isFrozen()) { + throw new FroozenError(); + } + if (this.size === 0) { + return this; + } + + if (!memory) { + params.object.clear.call(this); + return this; + } + + const slice = memory.getSlice(); + let memorySet = slice[params.ID as number] as MemoryTypeSet; + if (!memorySet) { + slice[params.ID as number] = memorySet = new params.MemoryType() as MemoryTypeSet; + } + + params.object.forEach.call(this, (item: T) => { + const itemParams = item && typeof item === 'object' && item[memoryProxyPramsKey]; + if (itemParams) { + item = itemParams.ID; + memory.deleteSliceProxyParent(itemParams.ID, params.ID, undefined); + } + memorySet.delete.add(item); + memorySet.add.delete(item); + }); + params.object.clear.call(this); + + memory.markDirty(params.ID); // mark the cache as invalid when change the slide memory + return this; + } + has(item: T): boolean { + const params = this[memoryProxyPramsKey]; + return params.object.has.call(this, item); + } + values(): IterableIterator { + const params = this[memoryProxyPramsKey]; + return params.object.values.call(this); + } + keys(): IterableIterator { + const params = this[memoryProxyPramsKey]; + return params.object.keys.call(this); + } + forEach(callback: (value: T, value2: T, set: Set) => void): void { + const params = this[memoryProxyPramsKey]; + return params.object.forEach.call(this, callback); + } + entries(): IterableIterator<[T, T]> { + const params = this[memoryProxyPramsKey]; + return params.object.entries.call(this); + } +} + +function proxifySyncSet(): void { + // Synchronization function + // Most methods of the "proxy" will call this synchronization function, even + // if it is not yet linked to a memory ! + const params = this as SetParams; + const memory = params.memory; + const object = params.object; + const proxy = params.proxy; + // get current object state in memory + const memorySet = memory.getSliceValue(params.ID) as MemoryCompiledSet; + + // reset all keys (+ explanation of best/worst case scenario) + object.clear.call(proxy); + if (!memorySet) { + return; + } + // Update values according to what is stored + memorySet.forEach(item => { + if (item instanceof VersionableID) { + item = memory.getProxy(item); + } + object.add.call(proxy, item); + }); +} + +// This will be set on the versionable params object and called with the params +// as the value of `this`. It is created here so that it is created only once ! +function linkVersionable(memory: MemoryWorker): void { + const params = this as SetParams; + params.memory = memory; + params.synchronize = proxifySyncSet; + memory.markDirty(params.ID); + if (!params.proxy.size) { + return; + } + const slice = memory.getSlice(); + + const memorySet = new params.MemoryType() as MemoryTypeSet; + slice[params.ID as number] = memorySet; // store the "pure" value in memory + params.object.forEach.call(params.proxy, (value: MemoryAllowedType): void => { + const valueParams = + value !== null && typeof value === 'object' && value[memoryProxyPramsKey]; + if (valueParams) { + // If object is versionable then link it to memory as well + memory.linkToMemory(value as object); + memory.addSliceProxyParent(valueParams.ID, params.ID, undefined); + memorySet.add.add(valueParams.ID); + } else { + memorySet.add.add(value); + } + }); +} + +export function _proxifySet(set: Set): VersionableSet { + const versionableSet = new VersionableSet(set); + set[memoryProxyPramsKey] = versionableSet[memoryProxyPramsKey]; + return versionableSet; +} diff --git a/packages/core/src/Memory/const.ts b/packages/core/src/Memory/const.ts new file mode 100644 index 000000000..01623a86b --- /dev/null +++ b/packages/core/src/Memory/const.ts @@ -0,0 +1,43 @@ +import { CustomError } from '../../../utils/src/errors'; + +export const memoryProxyNotVersionableKey = Symbol('jabberwockMemoryNotVersionable'); +export const memoryProxyPramsKey = Symbol('jabberwockMemoryParams'); +export const removedItem = Symbol('jabberwockMemoryRemovedItem'); +export const symbolVerify = Symbol('jabberwockMemoryVerify'); + +/** + * Creates an instance representing an error that occurs when theyr are any + * error in the memory feature or with the integration of the memory. + */ +export class MemoryError extends CustomError { + constructor(message?: string, ...params) { + super(message, ...params); + this.message = message || 'Jabberwok error in memory feature'; + } +} +export class NotVersionableError extends MemoryError { + constructor() { + super(); + this.message = + 'You can only link to the memory the instance of VersionableObject, VersionableArray or VersionableSet.' + + "\nIf that's not possible, then you can also use makeVersionable method on your custom object." + + '\nIf you do not want to make versionable this object, indicate it using MarkNotVersionable method' + + '\nPlease read the Jabberwock documentation.'; + } +} +export class VersionableAllreadyVersionableError extends MemoryError { + constructor() { + super(); + this.message = + 'This object was already update and a proxy was create to be versionable.' + + '\nPlease use it instead of the source object.'; + } +} +export class FroozenError extends MemoryError { + constructor() { + super(); + this.message = + 'This memory is froozen and immutable.' + + '\nYou can not update a memory version who content memory dependencies'; + } +} diff --git a/packages/core/src/Memory/test/memory.perf.ts b/packages/core/src/Memory/test/memory.perf.ts new file mode 100644 index 000000000..8cf628cbd --- /dev/null +++ b/packages/core/src/Memory/test/memory.perf.ts @@ -0,0 +1,458 @@ +/* eslint-disable max-nested-callbacks */ +import { expect } from 'chai'; + +import { Memory } from '../Memory'; +import { makeVersionable } from '../Versionable'; +import { VersionableArray } from '../VersionableArray'; +import { VersionableObject } from '../VersionableObject'; + +let ID = 0; +let Text = 0; + +class Node extends VersionableObject { + ID: number; + parent: Node; +} + +class TestCharNode extends Node { + bold: boolean; + char: string; + constructor(char: string) { + super(); + Text++; + this.ID = Text; + this.bold = true; + this.char = char; + } + display(): string { + return this.char; + } +} +class TestNode extends Node { + name: string; + attr: Record; + children: Node[]; + tagName: string; + constructor() { + super(); + ID++; + this.ID = ID; + this.name = '' + ID; + this.children = new VersionableArray() as Node[]; + this.attr = makeVersionable({ + b: new Set(['p', 'o' + ID]), + c: { + 'x': ID % 2 ? 'y' : 'z', + 'A': 'AAA', + }, + }) as Record; + } + + display(withSpace = false, level = 0): string { + const space = Array(2 * level).join(' '); + let str = (withSpace ? space : '') + this.name; + const add = (value: string, key: string): string => (str += key + ':' + value + ';'); + for (const key in this.attr) { + if (key === 'b') { + const m = []; + (this.attr[key] as Set).forEach(value => m.push(value)); + m.join(';'); + str += ' b<' + m.join(';') + '>'; + } else { + str += ' c['; + const style = this.attr[key]; + Object.keys(style).forEach(key => add(style[key], key)); + str += ']'; + } + } + level++; + if (this.children.length) { + str += '{'; + if (withSpace) { + str += '\n'; + } + const a = []; + this.children.forEach(child => a.push((child as TestNode).display(withSpace, level))); + str += a.join('|'); + if (withSpace) { + str += '\n' + space; + } + str += '}'; + } + return str; + } +} + +function addChildren( + memory: Memory, + root: TestNode, + deep: number, + num: number, + textLength?: number, +): void { + const children = []; + for (let k = 0; k < num; k++) { + const child = new TestNode(); + if (deep > 0) { + addChildren(memory, child, deep - 1, num, textLength); + } else if (textLength) { + const texts = []; + for (let i = 0; i < textLength; i++) { + const child = new TestCharNode('a'); + texts.push(child); + } + child.children.push(...texts); + } + memory.attach(child); + children.push(child); + } + root.children.push(...children); +} +function simulateAddSection(memory: Memory, root: TestNode): TestNode { + const section = new TestNode(); + root.children.push(section); + addChildren(memory, section, 2, 3, 30); + return section; +} +function read(node: Node): void { + if (node instanceof TestNode) { + // eslint-disable-next-line no-unused-expressions + node.name; + (node.attr.b as Set).has('p'); + // eslint-disable-next-line no-unused-expressions + node.attr.c; + // eslint-disable-next-line no-unused-expressions + (node.attr.c as Record).x; + // eslint-disable-next-line no-unused-expressions + node.children[0]; + } else if (node instanceof TestCharNode) { + // eslint-disable-next-line no-unused-expressions + node.char; + } +} +function readAll(node: Node, nodes?: Node[]): Node[] { + nodes = nodes || []; + nodes.push(node); + read(node); + const children = (node as TestNode).children; + if (children) { + for (let k = 0, len = children.length; k < len; k++) { + const node = children[k]; + readAll(node as Node, nodes); + } + } + return nodes; +} + +describe('test performances', () => { + describe('core', () => { + describe('state', () => { + describe('Memory', () => { + let memory: Memory; + let array: VersionableArray; + before(() => { + memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + array = new VersionableArray(); + }); + it('Create and Link 100 arrays and add inside 1000 items', () => { + const d = Date.now(); + for (let k = 0; k < 10; k++) { + array = new VersionableArray(); + memory.attach(array); + for (let k = 0; k < 1000; k++) { + array.push(k); + } + } + const dt = Date.now() - d; + expect(dt).to.lessThan(150); + }); + it('array forEach (with length 1000)', () => { + const d = Date.now(); + const fn = function emptyCallbackForTest(): void { + return; + }; + for (let k = 0; k < 1000; k++) { + array.forEach(fn); + } + const dt = Date.now() - d; + expect(dt).to.lessThan(10, 'Time to use 1000 * forEach'); + }); + it('array indexOf (with length 1000)', () => { + const d = Date.now(); + for (let k = 0; k < 1000; k++) { + array.indexOf(k); + } + const dt = Date.now() - d; + expect(dt).to.lessThan(10, 'Time to use 1000 * indexOf'); + }); + }); + describe('versionableNode', () => { + describe('children, parent, attributes', () => { + ID = 0; + const memory = new Memory(); + + memory.create('1'); + memory.switchTo('1'); + const root = new TestNode(); + memory.attach(root); + + it('create and read nodes', () => { + addChildren(memory, root, 0, 1); + + expect(root.display()).to.equal( + '1 b c[x:y;A:AAA;]{2 b c[x:z;A:AAA;]}', + ); + + memory.create('1-1'); + memory.create('1-2'); + + memory.switchTo('1-1'); + addChildren(memory, root, 1, 2); + expect(root.display()).to.equal( + '1 b c[x:y;A:AAA;]{2 b c[x:z;A:AAA;]|3 b c[x:y;A:AAA;]{4 b c[x:z;A:AAA;]|5 b c[x:y;A:AAA;]}|6 b c[x:z;A:AAA;]{7 b c[x:y;A:AAA;]|8 b c[x:z;A:AAA;]}}', + ); + + memory.switchTo('1-2'); + addChildren(memory, root, 0, 2); + expect(root.display()).to.equal( + '1 b c[x:y;A:AAA;]{2 b c[x:z;A:AAA;]|9 b c[x:y;A:AAA;]|10 b c[x:z;A:AAA;]}', + ); + + memory.switchTo('1-1'); + memory.create('1-1-55'); + memory.switchTo('1-1-55'); + + const child = root.children[1] as TestNode; + addChildren(memory, child, 0, 5); + let nb = 0; + Object.keys(memory._slices['1-1-55'].data).forEach((key: string) => { + if (!isNaN(+key)) { + nb++; + } + }); + expect(nb).to.equal(21); // should create 5 nodes (5 nodes * 4 (node + attr + b + c) (not the empty children) + 1 children list) + memory.create('1-1-55-1'); + memory.switchTo('1-1-55-1'); + child.children.splice(1, 3); + const slice = memory._slices['1-1-55-1'].data; + expect(Object.keys(slice).length).to.equal(1); // one patch + }); + }); + describe('Performance (double time to have an error)', () => { + ID = 0; + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + const root = new TestNode(); + memory.attach(root); + + it('Should write 100 chars in maximum 100ms', () => { + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + const root = new TestNode(); + memory.attach(root); + + const section = simulateAddSection(memory, root); + const child = (section.children[0] as TestNode).children[1] as TestNode; + + const d = Date.now(); + for (let k = 0; k < 100; k++) { + memory.create('test-' + k); + memory.switchTo('test-' + k); + const children = child.children; + // add char + children.push(new TestCharNode(k.toString())); + // simulate read new text for redraw + for (let k = 0, len = children.length; k < len; k++) { + const node = children[k]; + if (node instanceof TestCharNode) { + // eslint-disable-next-line no-unused-expressions + node.char; + } + } + } + const dt = Date.now() - d; + + expect(dt).to.lessThan(100); + }); + function testAddAndRead(memory: Memory, root: TestNode, nb: number): number { + for (let k = 0; k < nb; k++) { + memory.create('1-t-' + k); + memory.switchTo('1-t-' + k); + } + + memory.create('1-t'); + memory.switchTo('1-t'); + + const section = simulateAddSection(memory, root); + + memory.switchTo('test'); + memory.switchTo('1-t'); + + const d = Date.now(); + const nodes = readAll(section); + const dt = Date.now() - d; + + let nodesNb = 0; + let chars = 0; + for (let k = 0, len = nodes.length; k < len; k++) { + const node = nodes[k]; + if (node instanceof TestNode) { + nodesNb++; + } else { + chars++; + } + } + + console.info( + 'Time to load ' + + nodesNb + + ' nodes and ' + + chars + + ' chars on ' + + nb + + ' slices', + dt, + ); + + const d2 = Date.now(); + readAll(section); + const dt2 = Date.now() - d2; + console.info( + 'Time to re-load ' + + nodesNb + + ' nodes and ' + + chars + + ' chars on ' + + nb + + ' slices', + dt2, + ); + + return dt; + } + it('Should create and nodes with 10 slices in minimum time', () => { + memory.switchTo('1'); + memory.create('test'); + memory.switchTo('test'); + const dt = testAddAndRead(memory, root, 10); + expect(dt).to.lessThan(50); + }); + it('Should remove 10 slices in minimum time', () => { + const d = Date.now(); + memory.switchTo('1'); + memory.remove('test'); + const dt = Date.now() - d; + expect(dt).to.lessThan(6); + }); + it('Should create and nodes with 100 slices in maximum 30ms', () => { + memory.switchTo('1'); + memory.create('test'); + memory.switchTo('test'); + const dt = testAddAndRead(memory, root, 100); + expect(dt).to.lessThan(50); + }); + it('Should remove 100 slices in minimum time', () => { + const d = Date.now(); + memory.switchTo('1'); + memory.remove('test'); + const dt = Date.now() - d; + expect(dt).to.lessThan(6); + }); + it('Should read nodes with 1.000 slices in maximum 30ms', () => { + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + const root = new TestNode(); + memory.attach(root); + const dt = testAddAndRead(memory, root, 1000); + expect(dt).to.lessThan(50); + }); + it('Should create a lot of nodes in minimum time', () => { + memory.switchTo('1'); + memory.create('test'); + memory.switchTo('test'); + + memory.create('1-3'); + memory.switchTo('1-3'); + + const d = Date.now(); + const nodeInti = ID; + const textInti = Text; + for (let k = 0; k < 30; k++) { + simulateAddSection(memory, root); + } + const dt = Date.now() - d; + + console.info( + 'Time to create ' + + (ID - nodeInti) + + ' nodes and ' + + (Text - textInti) + + ' chars', + dt, + ); + expect(dt).to.lessThan(1000); // perf to create node + }); + it('Should switch slice in minimum time', () => { + const d = Date.now(); + memory.switchTo('test'); + memory.create('1-2'); + memory.switchTo('1-3'); + memory.create('1-3-1'); + memory.switchTo('1-2'); + memory.switchTo('1-3-1'); + const dt = Date.now() - d; + console.info('Time to switch memory', dt); + expect(dt).to.lessThan(50); + }); + it('Should read the nodes the first time in minimum time', () => { + memory.switchTo('1-3-1'); + const d = Date.now(); + readAll(root); + const dt = Date.now() - d; + console.info('Time to load nodes', dt); + expect(dt).to.lessThan(250); + }); + it('Should read the nodes in minimum time', () => { + const times = []; + + const d = Date.now(); + const nodes = readAll(root); + const dt = Date.now() - d; + times.push(dt); + console.info('Time to re-load nodes', dt); + + for (let k = 0; k < 5; k++) { + const d = Date.now(); + // nodes.forEach(read); + readAll(root); + const dt = Date.now() - d; + times.push(dt); + console.info('Time to re-load nodes', dt); + } + + let nodesNb = 0; + let chars = 0; + nodes.forEach(node => { + if (node instanceof TestNode) { + nodesNb++; + } else { + chars++; + } + }); + const average = Math.round(times.reduce((a, b) => a + b) / times.length); + console.info( + 're-load ' + nodesNb + ' nodes and ' + chars + ' chars time average', + average, + ); + expect(average).to.lessThan(90); + }); + }); + }); + }); + }); +}); diff --git a/packages/core/src/Memory/test/memory.test.ts b/packages/core/src/Memory/test/memory.test.ts new file mode 100644 index 000000000..501ea7dfe --- /dev/null +++ b/packages/core/src/Memory/test/memory.test.ts @@ -0,0 +1,2463 @@ +/* eslint-disable max-nested-callbacks */ +import { expect } from 'chai'; +import { Memory, markAsDiffRoot } from '../Memory'; +import { VersionableObject } from '../VersionableObject'; +import { VersionableArray } from '../VersionableArray'; +import { VersionableSet } from '../VersionableSet'; +import { makeVersionable, markNotVersionable, proxifyVersionable } from '../Versionable'; +import { memoryProxyPramsKey } from '../const'; + +describe('core', () => { + describe('state', () => { + describe('memory', () => { + describe('throw', () => { + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + + it('if try create a slice twice', () => { + expect((): Memory => memory.create('1')).to.throw('already exists'); + }); + it('if try remove a slice before switch on an other', () => { + memory.create('rem-2'); + memory.switchTo('rem-2'); + memory.create('rem-3'); + memory.switchTo('rem-3'); + expect((): Memory => memory.remove('rem-2')).to.throw('switch'); + }); + it('if try remove the original slice', () => { + expect((): Memory => memory.remove('')).to.throw('original'); + }); + it('if try switch on a undefined slice', () => { + expect((): Memory => memory.switchTo('2')).to.throw('must create'); + }); + it('if try to link a non versionable object', () => { + expect((): void => { + memory.attach({ + test: 1, + }); + }).to.throw('VersionableObject', 'object'); + expect((): void => { + memory.attach([1]); + }).to.throw('VersionableObject', 'array'); + expect((): void => { + memory.attach(new Set([1])); + }).to.throw('VersionableObject', 'set'); + }); + it('if try to link a non versionable object from proxy Object.assing', () => { + const versionable = makeVersionable({ test: 1 }); + const obj = Object.assign({}, versionable); + expect((): void => { + memory.attach(obj); + }).to.throw('VersionableObject'); + }); + it('if try to add as attribute a non versionable object from proxy Object.assing', () => { + const versionable = makeVersionable({ test: 1 }); + const obj = Object.assign({}, versionable); + const ref = makeVersionable({ toto: undefined }); + expect((): void => { + ref.toto = obj; + }).to.throw('VersionableObject'); + }); + it('if try to link an object and not the proxy of versionable', () => { + const obj = { test: 1 }; + makeVersionable(obj); + expect((): void => { + memory.attach(obj); + }).to.throw('already'); + }); + it('if try to add as attribute an object and not the proxy of versionable', () => { + const obj = { test: 1 }; + const ref = makeVersionable({ toto: undefined }); + makeVersionable(obj); + expect((): void => { + ref.toto = obj; + }).to.throw('already'); + }); + it('if try to link a versionable object to 2 memories', () => { + const memoryTest = new Memory(); + memoryTest.create('1'); + memoryTest.switchTo('1'); + const obj = new VersionableObject(); + memoryTest.attach(obj); + const array = new VersionableArray(); + memoryTest.attach(array); + const set = new VersionableSet(); + memoryTest.attach(set); + + expect((): void => { + memory.attach(obj); + }).to.throw('other memory', 'object'); + expect((): void => { + memory.attach(array); + }).to.throw('other memory', 'array'); + expect((): void => { + memory.attach(set); + }).to.throw('other memory', 'set'); + }); + it('if try to link a versionable from an other memory in versionable', () => { + const memoryTest = new Memory(); + memoryTest.create('1'); + memoryTest.switchTo('1'); + const root = new VersionableObject(); + memory.attach(root); + const obj = new VersionableObject(); + memoryTest.attach(obj); + const array = new VersionableArray(); + memoryTest.attach(array); + const set = new VersionableSet(); + memoryTest.attach(set); + + expect((): void => { + root['x+y'] = obj; + }).to.throw('other memory', 'object'); + expect((): void => { + root['x+y'] = array; + }).to.throw('other memory', 'array'); + expect((): void => { + root['x+y'] = set; + }).to.throw('other memory', 'set'); + }); + it('if try to makeVersionable on a object already versionable with the old ref', () => { + const obj = {}; + makeVersionable(obj); + expect((): void => { + makeVersionable(obj); + }).to.throw('proxy', 'object'); + const array = []; + makeVersionable(array); + expect((): void => { + makeVersionable(array); + }).to.throw('proxy', 'array'); + const set = new Set(); + makeVersionable(set); + expect((): void => { + makeVersionable(set); + }).to.throw('proxy', 'set'); + }); + it('if try to link an object in versionable', () => { + const obj = new VersionableArray(); + expect((): void => { + obj['t+h'] = {}; + }).to.throw('VersionableObject', 'object'); + + const array = new VersionableArray(); + expect((): void => { + array.push({}); + }).to.throw('VersionableObject', 'array'); + + const set = new VersionableSet(); + expect((): void => { + set.add({}); + }).to.throw('VersionableObject', 'set'); + }); + it('if try to link an object in custom class constructor', () => { + class SuperObject extends VersionableObject { + obj: object; + constructor() { + super(); + this.obj = {}; + } + } + expect((): SuperObject => new SuperObject()).to.throw('VersionableObject'); + }); + it('if try to link an object in custom class', () => { + class SuperObject extends VersionableObject { + obj: object; + myMethod(): void { + this.obj = {}; + } + } + const instance = new SuperObject(); + expect((): void => { + instance.myMethod(); + }).to.throw('VersionableObject'); + }); + it('if try to edit attribute in a froozen slice', () => { + const obj = new VersionableObject(); + memory.attach(obj); + memory.switchTo('1'); + memory.create('1-1'); + memory.switchTo('1'); + expect((): number => (obj['H' + 1] = 4)).to.throw('can not update', 'object'); + memory.remove('1-1'); + }); + it('if try to edit array in a froozen slice', () => { + const memory = new Memory(); + const array = new VersionableArray(); + memory.attach(array); + memory.create('1'); + memory.switchTo('1'); + memory.create('2'); + expect((): number => array.push(4)).to.throw('can not update', 'array'); + expect((): number => (array[1] = 3)).to.throw('can not update', 'array'); + memory.switchTo('1'); + memory.remove('2'); + }); + it('if try to edit set in a froozen slice', () => { + const memory = new Memory(); + const set = new VersionableSet(); + memory.attach(set); + memory.create('1'); + memory.switchTo('1'); + memory.create('1-1'); + memory.switchTo('1'); + expect((): VersionableSet => set.add(4)).to.throw('can not update', 'add'); + expect((): boolean => set.delete(4)).to.throw('can not update', 'delete'); + expect((): VersionableSet => set.clear()).to.throw('can not update', 'clear'); + memory.remove('1-1'); + }); + it('if try to compress/snapshot with wrong slice', () => { + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.create('test-0'); + memory.switchTo('test-0'); + memory.create('test-1'); + memory.switchTo('test-1'); + memory.create('test-2'); + memory.switchTo('test-2'); + expect((): void => memory.snapshot('test-2', 'test', 'snap')).to.throw('merge'); + }); + }); + + describe('create versionable', () => { + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + + it('link a object marked as not versionable', () => { + const obj = new VersionableObject(); + const context = {}; + markNotVersionable(context); + memory.attach(obj); + // do not throw + obj['g+k'] = context; + }); + it('makeVersionable an object after Object.assing by a versionable', () => { + const versionable = makeVersionable({ test: 1 }); + // do not throw + const obj = makeVersionable(Object.assign({}, versionable)); + expect(obj.test).to.equal(1); + obj.test = 3; + expect(obj.test).to.equal(3); + expect(versionable.test).to.equal(1, 'Original object value'); + }); + it('custom class/object', () => { + class SuperObject extends VersionableObject { + my: number; + toto: number; + myMethod(num: number): void { + this.my = num; + } + } + const obj = new SuperObject(); + obj.toto = 99; + expect(obj.my).to.equal( + undefined, + 'Should keep undefined value on versionable', + ); + expect(obj.toto).to.equal(99, 'Should set a value on unlinked versionable'); + memory.attach(obj); + expect(obj.my).to.equal( + undefined, + 'Should keep undefined value when link to memory', + ); + expect(obj.toto).to.equal(99, 'Should keep value when link to memory'); + obj.myMethod(42); + expect(obj.my).to.equal(42, 'Should use custom method on versionable'); + expect(obj.toto).to.equal(99, 'Should keep same value'); + }); + it('custom Array', () => { + class SuperArray extends VersionableArray { + myMethod(num: number): void { + this.push(num); + } + } + const obj = new SuperArray(); + obj.push(1); + obj.myMethod(99); + expect(obj.join(',')).to.equal('1,99'); + expect(obj.length).to.equal(2); + memory.attach(obj); + expect(obj.join(',')).to.equal('1,99'); + expect(obj.length).to.equal(2); + obj.myMethod(42); + expect(obj.join(',')).to.equal('1,99,42'); + expect(obj.length).to.equal(3); + }); + it('use proxy of versionable array', () => { + const obj = makeVersionable({ test: [] }); + const array = new VersionableArray(); + const proxy = new Proxy(array, {}); + memory.attach(obj); + obj.test = proxy; + expect(obj.test).to.equal(proxy); + obj.test.push(9); + expect(array).to.deep.equal([9]); + }); + it('custom Set who extend versionableSet', () => { + class SuperSet extends VersionableSet { + myMethod(num: number): void { + this.add(num); + } + } + const obj = new SuperSet(); + obj.add(1); + obj.myMethod(99); + function join(obj: SuperSet): string { + const array = []; + obj.forEach((value: number): number => array.push(value)); + return array.join(','); + } + expect(join(obj)).to.equal('1,99'); + expect(obj.size).to.equal(2); + memory.attach(obj); + expect(join(obj)).to.equal('1,99'); + expect(obj.size).to.equal(2); + obj.myMethod(42); + expect(join(obj)).to.equal('1,99,42'); + expect(obj.size).to.equal(3); + }); + it('Set with default value', () => { + const obj = makeVersionable({ + a: 1, + b: new Set([{}]), + c: 3, + }); + const b = obj.b; + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.attach(obj); + memory.create('test'); + memory.switchTo('test'); + obj.b = new VersionableSet([1]); + expect([...obj.b]).to.deep.equal([1]); + memory.switchTo('1'); + expect(obj.b).to.equal(b, 'switch object'); + expect(obj.b.size).to.equal(1, 'one object'); + expect([...obj.b]).to.deep.equal( + [{}], + 'should have the set who contains an object', + ); + memory.switchTo('test'); + expect(obj.b.size).to.equal(1, 'one number'); + expect([...obj.b]).to.deep.equal( + [1], + 'should have the set who contains a number', + ); + }); + it('create versionableSet with array value to construct it', () => { + const set = new VersionableSet([1, 2, 3]); + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.attach(set); + expect(set.size).to.equal(3); + }); + it('create versionableSet with customSet value to construct it', () => { + class TestSet extends Set { + method(): number { + this.add(5); + return 3; + } + } + const set = new TestSet(); + set.add(3); + const proxy = (new VersionableSet(set) as unknown) as TestSet; + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.attach(proxy); + expect(proxy.size).to.equal(1); + expect(proxy.method()).to.equal(3); + }); + it('create versionableSet with versionableSet', () => { + const set = new VersionableSet(); + set.add(3); + expect(set.size).to.equal(1); + const set2 = new VersionableSet(set); + set2.add(4); + expect(set2.size).to.equal(2); + expect([...set2.values()]).to.deep.equal([3, 4]); + set.add(5); + expect(set2.size).to.equal(2); + }); + it('custom class who contains proxy of Versionable', () => { + class SuperObject extends VersionableObject { + obj: object; + ref = 0; + constructor() { + super(); + this.obj = proxifyVersionable(new VersionableObject(), { + set: (obj: object, prop: string, value: number): boolean => { + obj[prop] = value; + if (value === 42) { + this.ref++; + } + return true; + }, + }); + } + } + const instance = new SuperObject(); + expect(instance.ref).to.equal(0); + instance.obj['A'.toString()] = 99; + expect(instance.ref).to.equal(0); + instance.obj['A'.toString()] = 42; + expect(instance.ref).to.equal(1); + + memory.attach(instance); + + expect(instance.obj).to.be.an.instanceof(VersionableObject); + expect(instance.obj['A'.toString()]).to.equal(42); + expect(instance.ref).to.equal(1); + instance.obj['B'.toString()] = 99; + expect(instance.ref).to.equal(1); + instance.obj['B'.toString()] = 42; + expect(instance.ref).to.equal(2); + }); + it('custom class who contains get overwrite of Versionable', () => { + class SuperObject extends VersionableObject { + obj: Record; + constructor() { + super(); + this.obj = new VersionableObject() as Record; + Object.defineProperty(this.obj, 'truc', { + get: (): number => { + return 42; + }, + set: (): void => { + return; + }, + }); + } + + get getter(): Record { + return this.obj; + } + } + const instance = new SuperObject(); + instance.obj.A = 99; + expect(instance.obj.A).to.equal(99); + expect(instance.obj.truc).to.equal(42); + instance.obj.truc = 99; + expect(instance.obj.truc).to.equal(42); + expect(instance.obj).to.equal(instance.getter); + + memory.attach(instance); + + expect(instance.obj).to.be.an.instanceof(VersionableObject); + instance.obj.B = 99; + expect(instance.obj.B).to.equal(99); + expect(instance.obj.truc).to.equal(42); + instance.obj.truc = 99; + expect(instance.obj.truc).to.equal(42); + expect(instance.obj).to.equal(instance.getter); + }); + }); + + describe('make versionable', () => { + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + + it('object', () => { + const obj: Record = { + a: 1, + b: 2, + c: 3, + }; + Object.defineProperty(obj, 'z', { + get() { + return 42; + }, + enumerable: false, + configurable: false, + }); + const proxy = makeVersionable(obj); + memory.attach(proxy); + expect(proxy.a).to.equal(1); + expect(proxy.b).to.equal(2); + expect(proxy.c).to.equal(3); + expect(proxy.z).to.equal(42); + }); + it('object who contains Object', () => { + const obj = makeVersionable({ + a: 1, + b: { + x: 1, + y: 2, + }, + c: 3, + }); + memory.attach(obj); + expect(obj.a).to.equal(1); + expect(obj.b).to.be.a('object'); + expect(Object.keys(obj.b).join()).to.equal('x,y'); + expect(obj.b.x).to.equal(1); + expect(obj.b.y).to.equal(2); + expect(obj.c).to.equal(3); + }); + it('object who contains Object who contains Object', () => { + const obj = makeVersionable({ + a: 1, + b: { + x: 1, + y: { + e: 1, + y: 2, + }, + }, + c: 3, + }); + memory.attach(obj); + expect(obj.b).to.be.a('object'); + expect(Object.keys(obj.b).join()).to.equal('x,y'); + expect(obj.b.x).to.equal(1); + expect(obj.b.y).to.be.a('object'); + expect(Object.keys(obj.b.y).join()).to.equal('e,y'); + expect(obj.b.y.e).to.equal(1); + expect(obj.b.y.y).to.equal(2); + }); + it('object who contains Set', () => { + const obj = makeVersionable({ + a: 1, + b: new Set(['x', 'y', 'z']), + c: 3, + }); + memory.attach(obj); + expect(obj.a).to.equal(1); + expect(obj.b).to.be.instanceOf(Set); + expect(obj.b.has('x')).to.equal(true); + expect(obj.b.has('y')).to.equal(true); + expect(obj.b.has('z')).to.equal(true); + expect(obj.b.has('h')).to.equal(false); + expect(obj.c).to.equal(3); + }); + it('object who contains Array', () => { + const obj = makeVersionable({ + a: 1, + b: ['x', 'y', 'z'], + c: 3, + }); + memory.attach(obj); + expect(obj.a).to.equal(1); + expect(obj.b).to.be.instanceOf(Array); + expect(obj.b.indexOf('x')).to.equal(0, "should find 'x' at 0"); + expect(obj.b.indexOf('y')).to.equal(1, "should find 'y' at 0"); + expect(obj.b.indexOf('z')).to.equal(2, "should find 'z' at 0"); + expect(obj.b.indexOf('h')).to.equal(-1, "should not find 'h'"); + let str = ''; + obj.b.forEach(v => { + str += v; + }); + expect(str).to.equal('xyz', 'should use forEach'); + expect(obj.c).to.equal(3); + + memory.switchTo(''); + expect(obj).to.deep.equal({}, 'should not find object in other memory slice'); + memory.switchTo('1'); + expect(obj).to.deep.equal( + { + a: 1, + b: ['x', 'y', 'z'], + c: 3, + }, + 'Should have all values', + ); + }); + it('complexe', () => { + const full = makeVersionable({ + a: 1, + b: ['x', 'y', 'z'], + c: new Set(['x', 'y', 'z']), + d: { + x: 1, + y: { + e: 1, + y: 2, + }, + }, + e: [], + f: {}, + g: new Set(), + h: 'a', + i: [{ 'a': 1 }, 55], + j: new Set([{ 'b': 1 }]), + k: [['a'], ['b', 'c', 'd'], ['x', 'y', 'z']], + }); + + expect(!!full.i[0][memoryProxyPramsKey].ID).to.equal( + true, + 'proxify object in array', + ); + const list = []; + full.j.forEach((item: object) => { + list.push(item); + }); + expect(!!list[0][memoryProxyPramsKey].ID).to.equal( + true, + 'proxify object in set', + ); + expect(!!full.k[0][memoryProxyPramsKey].ID).to.equal( + true, + 'proxify array in array', + ); + + memory.attach(full); + + memory.create('1-a'); + memory.switchTo('1-a'); + full.k[1][3] = 'e'; + expect(full.k[1]).to.deep.equal( + ['b', 'c', 'd', 'e'], + 'should link to memory and change the array in array', + ); + memory.switchTo('1'); + expect(full.k[1]).to.deep.equal( + ['b', 'c', 'd'], + 'should have the previous value in the array in array', + ); + }); + it('make versionable a class who extends Set class', () => { + class TestSet extends Set { + method(): number { + this.add(5); + return 3; + } + } + let set = new TestSet(); + expect(set.size).to.equal(0, 'before link, set is empty'); + expect(set.method()).to.equal(3, 'before link, call method'); + expect(set.size).to.equal(1, 'before link, set contains the value'); + + set = makeVersionable(new TestSet()); + expect(set.size).to.equal(0, 'make versionable, set is empty'); + expect(set.method()).to.equal(3, 'make versionable, call method'); + expect(set.size).to.equal(1, 'make versionable, set contains the value'); + + set = makeVersionable(new TestSet()); + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.attach(set); + expect(set.size).to.equal(0, 'linked, set is empty'); + expect(set.method()).to.equal(3, 'linked, call method'); + expect(set.size).to.equal(1, 'linked, set contains the value'); + }); + }); + + describe('specific getter', () => { + it('object getter should keep proxy for "this"', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let obj: any = null; + let test = 0; + obj = makeVersionable({ + get test() { + if (obj) { + test++; + expect(this).to.equal(obj); + } + return test; + }, + }); + expect(obj.test).to.equal(1); + }); + it('custom class getter should keep proxy for "this"', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let obj: any = null; + let test = 0; + class Custom extends VersionableObject { + get test(): number { + if (obj) { + test++; + expect(this).to.equal(obj); + } + return test; + } + } + obj = new Custom(); + expect(obj.test).to.equal(1); + }); + it('extended custom class getter should keep proxy for "this"', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let obj: any = null; + let test = 0; + class Custom extends VersionableObject { + get test(): number { + if (obj) { + test++; + expect(this).to.equal(obj); + } + return test; + } + } + class CustomCustom extends Custom {} + obj = new CustomCustom(); + expect(obj.test).to.equal(1); + }); + it('overwrite toString method', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let obj: any = null; + class Custom extends VersionableObject { + toString(): string { + return '5'; + } + } + class CustomCustom extends Custom {} + obj = new CustomCustom(); + expect(typeof obj.toString).to.equal('function'); + expect(obj.toString()).to.equal('5'); + }); + it('get constructor', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let obj: any = null; + class Custom extends VersionableObject { + getConstructor(): string { + return this.constructor.name; + } + } + class CustomCustom extends Custom {} + obj = new CustomCustom(); + expect(obj.getConstructor()).to.equal('CustomCustom'); + }); + it.skip('array getter should keep proxy for "this" (use defineProperty)', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = new VersionableArray() as any; + let test = 0; + Object.defineProperty(obj, 'test', { + get() { + if (obj) { + test++; + expect(this).to.equal(obj); + } + return test; + }, + }); + expect(obj.test).to.equal(1); + }); + it('array slice', () => { + const memory = new Memory(); + const array = makeVersionable([1, 2, 3, 4, 5]); + memory.attach(array); + const newArray = array.slice(); + expect(newArray).to.deep.equal([1, 2, 3, 4, 5]); + expect(newArray[memoryProxyPramsKey]).to.equal(undefined); + }); + it('array slice on VersionableArray', () => { + const memory = new Memory(); + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + const newArray = array.slice(); + expect(newArray).to.deep.equal([1, 2, 3, 4, 5]); + expect(newArray[memoryProxyPramsKey].ID).to.not.equal( + array[memoryProxyPramsKey].ID, + ); + }); + it('array splice', () => { + const memory = new Memory(); + const array = makeVersionable([1, 2, 3, 4, 5]); + memory.attach(array); + const newArray = array.splice(1, 3); + expect(array).to.deep.equal([1, 5]); + expect(newArray).to.deep.equal([2, 3, 4]); + expect(newArray[memoryProxyPramsKey]).to.equal(undefined); + }); + it('array splice on VersionableArray', () => { + const memory = new Memory(); + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + const newArray = array.splice(1, 3); + expect(array).to.deep.equal([1, 5]); + expect(newArray).to.deep.equal([2, 3, 4]); + expect(newArray[memoryProxyPramsKey].ID).to.not.equal( + array[memoryProxyPramsKey].ID, + ); + }); + it('array forEach', () => { + const memory = new Memory(); + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + const values = []; + const indexes = []; + const arrays = []; + const res = array.forEach((value, index, array) => { + values.push(value); + indexes.push(index); + arrays.push(array); + return 9; + }); + expect(res).to.equal(undefined); + expect(values).to.deep.equal([1, 2, 3, 4, 5]); + expect(indexes).to.deep.equal([0, 1, 2, 3, 4]); + expect(arrays).to.deep.equal([array, array, array, array, array]); + }); + it('array map', () => { + const memory = new Memory(); + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + const values = []; + const indexes = []; + const arrays = []; + const res = array.map((value: number, index, array) => { + values.push(value); + indexes.push(index); + arrays.push(array); + return 8 + value; + }); + expect(res[memoryProxyPramsKey]).to.equal(undefined); + expect(res).to.deep.equal([9, 10, 11, 12, 13]); + expect(values).to.deep.equal([1, 2, 3, 4, 5]); + expect(indexes).to.deep.equal([0, 1, 2, 3, 4]); + expect(arrays).to.deep.equal([array, array, array, array, array]); + }); + it('array filter', () => { + const memory = new Memory(); + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + const values = []; + const indexes = []; + const arrays = []; + const res = array.filter((value: number, index, array) => { + values.push(value); + indexes.push(index); + arrays.push(array); + return !!(value % 2); + }); + expect(res[memoryProxyPramsKey]).to.equal(undefined); + expect(res).to.deep.equal([1, 3, 5]); + expect(values).to.deep.equal([1, 2, 3, 4, 5]); + expect(indexes).to.deep.equal([0, 1, 2, 3, 4]); + expect(arrays).to.deep.equal([array, array, array, array, array]); + }); + it('array indexOf', () => { + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + + const result = []; + const array = new VersionableArray(); + for (let k = 0; k < 20; k++) { + array.push(k); + result.push(k); + } + memory.attach(array); + for (let k = 20; k < 40; k++) { + array.push(k); + result.push(k); + } + + for (let k = 0, len = array.length; k < len; k++) { + expect(array.indexOf(k)).to.equal(k); + } + expect(array.indexOf(-99)).to.equal(-1); + + array[3] = -3; + + expect(array.indexOf(3)).to.equal(-1); + expect(array.indexOf(-3)).to.equal(3); + }); + it('array indexOf value several times', () => { + const array = new VersionableArray(0, 1, 2, 3, 1, 4); + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(array); + + expect(array.indexOf(1)).to.equal(1, 'should return the first index'); + array[1] = 42; + expect(array.indexOf(1)).to.equal(4, 'should return the other index'); + expect(array.indexOf(4)).to.equal(5, 'should the index'); + array[1] = 4; + expect(array.indexOf(4)).to.equal(1, 'should the newest index'); + }); + }); + + describe('specific setter', () => { + it('object setter should keep proxy for "this"', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let obj: any = null; + let test = 0; + obj = makeVersionable({ + get test() { + return 0; + }, + set test(x: number) { + test++; + expect(this).to.equal(obj); + }, + }); + obj.test = 1; + expect(test).to.equal(1); + }); + it('custom class setter should keep proxy for "this"', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let obj: any = null; + let test = 0; + class Custom extends VersionableObject { + get test(): number { + return 0; + } + set test(x: number) { + test++; + expect(this).to.equal(obj); + } + } + obj = new Custom(); + obj.test = 1; + expect(test).to.equal(1); + }); + it('extended custom class setter should keep proxy for "this"', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let obj: any = null; + let test = 0; + class Custom extends VersionableObject { + get test(): number { + return 0; + } + set test(x: number) { + test++; + expect(this).to.equal(obj); + } + } + class CustomCustom extends Custom {} + obj = new CustomCustom(); + obj.test = 1; + expect(test).to.equal(1); + }); + it('array setter should keep proxy for "this"', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = new VersionableArray() as any; + let test = 0; + Object.defineProperty(obj, 'test', { + get() { + return 0; + }, + set() { + test++; + expect(this).to.equal(obj); + }, + }); + obj.test = 1; + expect(test).to.equal(1); + }); + it('use Object.assing on versionable object', () => { + const versionable = makeVersionable({ test: 1 }); + const obj = makeVersionable({ test: 5 }); + Object.assign(obj, versionable); + expect(obj[memoryProxyPramsKey]).to.not.equal(versionable[memoryProxyPramsKey]); + expect(obj.test).to.equal(1); + obj.test = 3; + expect(obj.test).to.equal(3); + expect(versionable.test).to.equal(1, 'Original object value'); + }); + it('use Object.assing on versionable array', () => { + const versionable = makeVersionable([1, 2, 3]); + const array = makeVersionable([]); + Object.assign(array, versionable); + expect(array[memoryProxyPramsKey]).to.not.equal( + versionable[memoryProxyPramsKey], + ); + expect(array).to.deep.equal([1, 2, 3]); + array[1] = 42; + expect(array[1]).to.equal(42); + expect(versionable[1]).to.equal(2, 'Original object value'); + }); + it('array changes with splice', () => { + const memory = new Memory(); + const array = makeVersionable(['x', 'y', 'z']); + memory.attach(array); + memory.create('1'); + memory.create('test'); + memory.switchTo('test'); + array.splice(1, 0, 'B'); // ['x', 'B', 'y', 'z'] + array.push('z'); // ['x', 'B', 'y', 'z', 'z'] + array.splice(3, 0, 'A'); // ['x', 'B', 'y', 'A', 'z', 'z'] + + expect(array.slice()).to.deep.equal(['x', 'B', 'y', 'A', 'z', 'z']); + + array.splice(1, 2); // ['x', 'A', 'z', 'z'] + array[2] = 'y'; // ['x', 'A', 'y', 'z'] + + expect(array.slice()).to.deep.equal(['x', 'A', 'y', 'z'], 'before switch'); + + memory.switchTo('1'); + memory.switchTo('test'); + + expect(array.slice()).to.deep.equal(['x', 'A', 'y', 'z']); + }); + it('array changes with splice without second value', () => { + const memory = new Memory(); + const array = makeVersionable(['x', 'y', 'z']); + memory.attach(array); + memory.create('1'); + memory.create('test'); + memory.switchTo('test'); + array.splice(1); + memory.switchTo('1'); + memory.switchTo('test'); + expect(array.slice()).to.deep.equal(['x']); + }); + }); + + describe('specific updates', () => { + const memory = new Memory(); + const full = makeVersionable({ + a: 1, + b: ['x', 'y', 'z'], + c: new Set(['x', 'y', 'z']), + d: { + x: 1, + y: { + e: 1, + y: 2, + }, + }, + e: [], + f: {}, + g: new Set(), + h: 'a', + i: [{ 'a': 1 }, 55], + j: new Set([{ 'b': 1 }]) as Set, + }); + memory.create('1'); + memory.switchTo('1'); + memory.attach(full); + + it('remove an attribute', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + memory.create('test-b'); + memory.create('test-a'); + memory.switchTo('test-a'); + + full.d.y.e = 3; + delete full.d.y.y; + + memory.create('test-a-1'); + memory.switchTo('test-b'); + + full.d.y.e = 5; + full.d.y.y = 6; + + memory.switchTo('test-a-1'); + + full.d.y.e = 7; + + expect(full.d.y).to.deep.equal({ e: 7 }, 'after a change without read'); + + memory.switchTo('test-a'); + + expect(full.d.y).to.deep.equal( + { e: 3 }, + 'after swith check if memory writed on new slice', + ); + }); + it('remove twice an attribute', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + + memory.create('test-a'); + memory.create('test-b'); + memory.switchTo('test-a'); + + full.d.y.e = 3; + delete full.d.y.y; + + memory.create('test-a-1'); + memory.switchTo('test-b'); + + full.d.y.e = 5; + full.d.y.y = 6; + + memory.switchTo('test-a-1'); + + delete full.d.y.y; + + expect(full.d.y).to.deep.equal({ e: 3 }, 'after a change without read'); + + memory.switchTo('test-a'); + + expect(full.d.y).to.deep.equal( + { e: 3 }, + 'after swith check if memory writed on new slice', + ); + }); + it('update array properties', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + + const array = new VersionableArray(); + + const bibi = function toto(): number { + return 1; + }; + array['a+b'] = bibi; + + memory.attach(array); + + memory.create('test-a'); + memory.switchTo('test-a'); + + const toto = function toto(): number { + return 1; + }; + array['x+y'] = toto; + + memory.switchTo('test'); + expect(Object.keys(array)).to.deep.equal(['a+b'], 'has origin key'); + expect(array['x+y']).to.equal(undefined, "don't add method on other slice"); + expect(array['a+b']).to.equal(bibi, 'add method in the linked slice'); + memory.switchTo('test-a'); + expect(Object.keys(array)).to.deep.equal( + ['a+b', 'x+y'], + 'has origin and additional keys', + ); + expect(array['x+y']).to.equal(toto, 'add method on this slice'); + expect(array['a+b']).to.equal(bibi, 'add method in the child of linked slice'); + }); + it('update array properties with object', () => { + const obj0 = new VersionableObject({ z: 42 }); + const obj1 = new VersionableObject({ a: 1 }); + const obj2 = new VersionableObject({ b: 2 }); + const array = new VersionableArray(); + array['x+y'] = obj0; + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(array); + expect(array['x+y']).to.equal(obj0, 'add an object as property'); + array['x+y'] = obj1; + expect(array['x+y']).to.equal(obj1, 'add an object as property'); + array['x+y'] = obj2; + expect(array['x+y']).to.equal(obj2, 'change the property with an other object'); + delete array['x+y']; + expect(array).to.deep.equal([], 'remove the property'); + }); + it('remove array properties without memory', () => { + const array = new VersionableArray(); + array['x+y'] = 1; + expect(array['x+y']).to.equal(1, 'should have the default property'); + delete array['x+y']; + expect(array['x+y']).to.equal(undefined, 'should remove the property'); + }); + it('remove array properties in memory', () => { + const array = new VersionableArray(); + array['x+y'] = 1; + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(array); + expect(array['x+y']).to.equal(1, 'should have the default property'); + delete array['x+y']; + expect(array['x+y']).to.equal(undefined, 'should remove the property'); + memory.create('next'); + memory.switchTo('next'); + array['x+y'] = 2; + expect(array['x+y']).to.equal(2, 'should update the property'); + delete array['x+y']; + expect(array['x+y']).to.equal(undefined, 'should remove again the property'); + }); + it('create array with value only at index 5 and 10', () => { + const array = new VersionableArray(); + const res = []; + const obj = new VersionableObject({ a: 1 }); + res[5] = obj; + res[10] = obj; + array[5] = obj; + array[10] = obj; + expect(array).to.deep.equal(res, 'before link'); + const memory = new Memory(); + memory.create('a'); + memory.switchTo('a'); + memory.create('b'); + memory.switchTo('b'); + memory.attach(array); + expect(array).to.deep.equal(res, 'after link'); + memory.switchTo('a'); + memory.switchTo('b'); + expect(array).to.deep.equal(res, 'after switch slices'); + }); + it('add item in array at index 5', () => { + const array = new VersionableArray(); + const res = []; + const obj = new VersionableObject({ a: 1 }); + res[5] = obj; + res[10] = obj; + const memory = new Memory(); + memory.create('a'); + memory.switchTo('a'); + memory.attach(array); + memory.create('b'); + memory.switchTo('b'); + array[5] = obj; + array[10] = obj; + expect(array).to.deep.equal(res, 'after update'); + memory.switchTo('a'); + memory.switchTo('b'); + expect(array).to.deep.equal(res, 'after switch slices'); + }); + it('change array length', () => { + const array = new VersionableArray(1, 2, 3); + expect(array).to.deep.equal([1, 2, 3]); + + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(array); + expect(array).to.deep.equal([1, 2, 3]); + array.length = 1; + expect(array).to.deep.equal([1]); + }); + it('use set in array versionableSet', () => { + const set1 = new VersionableSet(); + const set2 = new VersionableSet(); + const array = new VersionableArray(set1, set2); + expect(array).to.deep.equal([set1, set2]); + + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(array); + expect(array).to.deep.equal([set1, set2]); + array.length = 1; + expect(array).to.deep.equal([set1]); + }); + it('unshift object in array', () => { + const obj = new VersionableObject({ a: 1 }); + const array = new VersionableArray(); + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(array); + array.unshift(obj); + expect(array).to.deep.equal([obj]); + }); + it('shift object in array', () => { + const obj = new VersionableObject(); + const array = new VersionableArray(obj); + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(array); + const shifted = array.shift(); + expect(array).to.deep.equal([]); + expect(shifted).to.equal(obj); + }); + it('update array in order', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + + const result = []; + const array = new VersionableArray(); + for (let k = 0; k < 5000; k++) { + array.push(k); + result.push(k); + } + + memory.attach(array); + expect(array).to.deep.equal(result, 'link this array'); + + for (let k = 0; k < 100; k++) { + array.unshift(k); + result.unshift(k); + } + + array.unshift(999); + result.unshift(999); + array.push(42); + result.push(42); + + memory.create('test-a'); + memory.switchTo('test-a'); + + array.push(2000); + + memory.switchTo('test'); + + expect(array).to.deep.equal(result); + }); + it('array multi splice in same memory slice', () => { + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + + const array = makeVersionable([1, 2, 3]); + memory.attach(array); + + memory.create('test-1'); + memory.switchTo('test-1'); + + array.splice(2, 0, 6, 7); + array.splice(1, 0, 4, 5); + expect(array.slice()).to.deep.equal([1, 4, 5, 2, 6, 7, 3]); + + const sub = array.splice(1, 5); + expect(array.slice()).to.deep.equal([1, 3]); + expect(sub).to.deep.equal([4, 5, 2, 6, 7]); + + memory.switchTo('test'); + memory.switchTo('test-1'); + expect(array.slice()).to.deep.equal([1, 3], 'Test internal memory'); + }); + it('default value for array', () => { + const memory = new Memory(); + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + memory.create('a'); + memory.switchTo('a'); + array[1] = 9; + expect(array).to.deep.equal([1, 9, 3, 4, 5]); + memory.switchTo(''); + memory.create('b'); + memory.switchTo('b'); + expect(array).to.deep.equal([1, 2, 3, 4, 5]); + }); + it('delete item in array', () => { + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + delete array[0]; + array[0] = 1; + delete array[1]; + array[1] = 9; + delete array[1]; + expect(array).to.deep.equal([1, undefined, 3, 4, 5]); + }); + it('delete item in array and switch memory', () => { + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + + memory.create('1-1'); + memory.switchTo('1-1'); + delete array[1]; + + expect(array).to.deep.equal([1, undefined, 3, 4, 5], 'remove one item'); + const array1 = array.slice(); + + memory.switchTo('test'); + memory.create('1-2'); + memory.switchTo('1-2'); + delete array[0]; + array[0] = 1; + delete array[1]; + array[1] = 9; + delete array[1]; + const array2 = array.slice(); + + expect(array2).to.deep.equal(array1, 'array have same value'); + }); + it('array push and pop in same slide memory have a clean memory slice values', () => { + const memory = new Memory(); + const array = new VersionableArray(1, 2, 3, 4, 5); + memory.attach(array); + memory.create('1'); + memory.switchTo('1'); + array.push(9); + array.push(9); + array.push(9); + array.pop(); + array.pop(); + const currentSlice = memory._currentSlice.data; + expect(currentSlice[array[memoryProxyPramsKey].ID]).to.deep.equal({ + props: {}, + patch: { '094': 9 }, + }); + }); + it('use splice and switch memory', () => { + const memory = new Memory(); + const array = makeVersionable(['x', 'y', 'z']); + memory.attach(array); + + memory.create('test'); + memory.switchTo('test'); + + memory.create('1-1'); + memory.switchTo('1-1'); + array.splice(1, 0, 'A'); + + expect(array.slice()).to.deep.equal(['x', 'A', 'y', 'z']); + + memory.switchTo('test'); + memory.create('1-2'); + memory.switchTo('1-2'); + array.splice(1, 0, 'B'); + + expect(array.slice()).to.deep.equal(['x', 'B', 'y', 'z']); + }); + it('move item but finish at the same index keep same order', () => { + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + + const array = makeVersionable([1, 2, 3]); + memory.attach(array); + + memory.create('test-1'); + memory.switchTo('test-1'); + + array.splice(2, 0, 6, 7); + array.splice(1, 0, 4, 5); + array.splice(1, 5); + + array.splice(1, 0, 2); + expect(array.slice()).to.deep.equal([1, 2, 3]); + + memory.switchTo('test'); + memory.switchTo('test-1'); + + expect(array.slice()).to.deep.equal([1, 2, 3], 'Test internal memory'); + }); + it('create object in a slice and read it in other', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + memory.create('test-a'); + memory.switchTo('test-a'); + const obj = new VersionableObject(); + obj['x+y'] = 3; + memory.attach(obj); + const array = new VersionableArray(); + array.push(3); + memory.attach(array); + const set = new VersionableSet(); + set.add(3); + memory.attach(set); + memory.switchTo('test'); + expect(obj['x+y']).to.equal(undefined); + expect(array).to.deep.equal([]); + expect(set).to.deep.equal(new Set()); + }); + it('remove attribute on un-linked object', () => { + const obj = makeVersionable({ a: 1, b: 2 }); + delete obj.a; + expect(obj).to.deep.equal({ b: 2 }, 'before link'); + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.attach(obj); + expect(obj).to.deep.equal({ b: 2 }, 'after link'); + }); + it('remove object in set', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + memory.create('test-a'); + memory.switchTo('test-a'); + let jObj: object; + full.j.forEach((obj: object) => { + jObj = obj; + }); + full.j.delete(new VersionableObject()); + expect(full.j).to.deep.equal( + new Set([jObj]), + 'delete an object not contains in set', + ); + full.j.delete(jObj); + expect(full.j).to.deep.equal(new Set(), 'delete the object'); + full.j.clear(); + expect(full.j).to.deep.equal(new Set(), 'clear empty set'); + full.j.add(jObj); + full.j.clear(); + expect(full.j).to.deep.equal(new Set(), 'clear owner slice set'); + memory.switchTo('test'); + expect(full.j).to.deep.equal(new Set([jObj]), 'other slice value'); + memory.create('test-b'); + memory.switchTo('test-b'); + full.j.clear(); + expect(full.j).to.deep.equal(new Set(), 'clear set'); + }); + it('add function in set', () => { + const set = new VersionableSet(); + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(set); + function a(): void { + return; + } + function b(): void { + return; + } + set.add(a); + expect(set).to.deep.equal(new Set([a])); + memory.create('other'); + memory.switchTo('other'); + set.add(b); + expect(set).to.deep.equal(new Set([a, b])); + }); + it('clear versionableSet', () => { + let set = new VersionableSet([1, 2, 3]); + expect(set.size).to.equal(3); + expect(set.has(1)).to.equal(true); + set.clear(); + expect(set.size).to.equal(0); + expect(set.has(1)).to.equal(false); + + set = new VersionableSet([1, 2, 3]); + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(set); + set.clear(); + expect(set.size).to.equal(0, 'linked'); + expect(set.has(1)).to.equal(false, 'linked'); + }); + it('replace attribute by set', () => { + const obj = makeVersionable({ + set: new Set([1, 2, 3]), + }); + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(obj); + + const set = new VersionableSet([4, 5, 6]); + + expect(obj).to.deep.equal({ + set: new Set([1, 2, 3]), + }); + memory.create('other'); + memory.switchTo('other'); + obj.set = set as Set; + expect(obj).to.deep.equal({ + set: new Set([4, 5, 6]), + }); + memory.switchTo('test'); + expect(obj).to.deep.equal( + { + set: new Set([1, 2, 3]), + }, + 'check previous value', + ); + memory.switchTo('other'); + expect(obj).to.deep.equal( + { + set: new Set([4, 5, 6]), + }, + 'check again', + ); + }); + }); + + describe('mutliple updates', () => { + const memory = new Memory(); + const full = makeVersionable({ + a: 1, + b: ['x', 'y', 'z'], + c: new Set(['x', 'y', 'z']), + d: { + x: 1, + y: { + e: 1, + y: 2, + }, + }, + e: [], + }); + + it('Link object', () => { + memory.create('1'); + memory.switchTo('1'); + memory.attach(full); + }); + it('should update an object (1)', () => { + full.a = 42; + expect(full.a).to.equal(42); + }); + it('should update an object in object (1)', () => { + full.d.y.e = 42; + expect(full.d.y.e).to.equal(42); + }); + it('should update a set in object (1)', () => { + full.c.delete('y'); + full.c.add('aaa'); + + const items = []; + full.c.forEach(v => { + items.push(v); + }); + expect(items.join()).to.equal('x,z,aaa'); + }); + it('should update an array in object (1)', () => { + full.b.push('t'); + full.b.splice(1, 2); + expect(full.b.join()).to.equal('x,t'); + }); + it('should have all object values (1)', () => { + expect(JSON.stringify(full)).to.equal( + JSON.stringify({ + a: 42, + b: ['x', 't'], + c: new Set(['x', 'z', 'aaa']), + d: { + x: 1, + y: { + e: 42, + y: 2, + }, + }, + e: [], + }), + ); + }); + + it('create slice memory (1)', () => { + memory.create('1-1'); + memory.create('1-2'); + }); + + it('should have the same value in the created slide', () => { + memory.switchTo('1'); + memory.switchTo('1-1'); + expect(full.a).to.equal(42, "number 'a'"); + expect(full.d.y.e).to.equal(42, "number in object 'd.y.e'"); + expect(full.b).to.deep.equal( + ['x', 't'], + "push 't' and splice(1, 2) from ['x', 'y', 'z']", + ); + const items = []; + full.c.forEach(v => { + items.push(v); + }); + expect(items.join()).to.equal( + 'x,z,aaa', + "delete 'y' and add 'aaa' in Set(['x', 'y', 'z'])", + ); + expect(JSON.stringify(full)).to.equal( + JSON.stringify({ + a: 42, + b: ['x', 't'], + c: new Set(['x', 'z', 'aaa']), + d: { + x: 1, + y: { + e: 42, + y: 2, + }, + }, + e: [], + }), + ); + memory.switchTo('1'); + }); + + it('should have memory slice values', () => { + memory.switchTo('1'); + memory.switchTo('1-1'); + full.a = 3; + expect(full.a).to.equal(3); + memory.switchTo('1-2'); + expect(full.a).to.equal(42); + memory.switchTo('1'); + }); + it('should switch memory slice (1 -> 1-1)', () => { + memory.switchTo('1-1'); + }); + it('should update an object (1-1)', () => { + full.a = 5; + expect(full.a).to.equal(5); + }); + it('should update an object in object (1-1)', () => { + full.d.y.y = 5; + expect(full.d.y.y).to.equal(5); + }); + it('should update a set in object (1-1)', () => { + full.c.delete('aaa'); + full.c.add('bbb'); + full.c.add('ccc'); + + const items = []; + full.c.forEach(v => { + items.push(v); + }); + expect(items.join()).to.equal('x,z,bbb,ccc'); + }); + it('should update an array in object (1-1)', () => { + full.b.unshift('o'); + expect(full.b.join()).to.equal('o,x,t', "unshift 'o' in ['x', 't']"); + }); + it('should have all object values (1-1)', () => { + const items = []; + full.c.forEach(v => { + items.push(v); + }); + expect(items.join()).to.equal( + 'x,z,bbb,ccc', + "delete 'aaa' and add 'bbb' + 'ccc' in Set(['x' , 'z', 'aaa'])", + ); + + expect(JSON.stringify(full)).to.equal( + JSON.stringify({ + a: 5, + b: ['o', 'x', 't'], + c: new Set(['x', 'z', 'bbb', 'ccc']), + d: { + x: 1, + y: { + e: 42, + y: 5, + }, + }, + e: [], + }), + ); + }); + it('should switch to an other memory slice (1-1 -> 1-2)', () => { + memory.switchTo('1-2'); + }); + it('should read the content without previous slice updates (1-2)', () => { + expect(JSON.stringify(full)).to.equal( + JSON.stringify({ + a: 42, + b: ['x', 't'], + c: new Set(['x', 'z', 'aaa']), + d: { + x: 1, + y: { + e: 42, + y: 2, + }, + }, + e: [], + }), + ); + const items = []; + full.c.forEach(v => { + items.push(v); + }); + expect(items.join()).to.equal('x,z,aaa'); + }); + it('should update an object (1-2)', () => { + full.a = 3; + expect(full.a).to.equal(3); + }); + it('should update an object in object (1-2)', () => { + full.d.y.y = 9; + expect(full.d.y.y).to.equal(9); + }); + it('should update a set in object (1-2)', () => { + full.c.delete('x'); + + const items = []; + full.c.forEach(v => { + items.push(v); + }); + expect(items.join()).to.equal('z,aaa'); + }); + it('should update an array in object (1-2)', () => { + full.b.splice(1, 1, 'OO'); + expect(full.b.join()).to.equal('x,OO'); + }); + it('should have all object values (1-2)', () => { + expect(JSON.stringify(full)).to.equal( + JSON.stringify({ + a: 3, + b: ['x', 'OO'], + c: new Set(['z', 'aaa']), + d: { + x: 1, + y: { + e: 42, + y: 9, + }, + }, + e: [], + }), + ); + }); + it('should add a key in memory and have all object values (1-2)', () => { + memory.attach(full); + expect(JSON.stringify(full)).to.equal( + JSON.stringify({ + a: 3, + b: ['x', 'OO'], + c: new Set(['z', 'aaa']), + d: { + x: 1, + y: { + e: 42, + y: 9, + }, + }, + e: [], + }), + ); + }); + + it('should switch to an other memory slice (1-2 -> 1-1)', () => { + memory.switchTo('1-1'); + }); + + it('should have again all object values (1-1)', () => { + expect(full.b.join()).to.equal('o,x,t', "unshift 'o' in ['x', 't']"); + const items = []; + full.c.forEach(v => { + items.push(v); + }); + expect(items.join()).to.equal( + 'x,z,bbb,ccc', + "delete 'aaa' and add 'bbb' + 'ccc' in Set(['x' , 'z', 'aaa'])", + ); + + expect(JSON.stringify(full)).to.equal( + JSON.stringify({ + a: 5, + b: ['o', 'x', 't'], + c: new Set(['x', 'z', 'bbb', 'ccc']), + d: { + x: 1, + y: { + e: 42, + y: 5, + }, + }, + e: [], + }), + ); + }); + }); + + describe('update without read values', () => { + const memory = new Memory(); + const full = makeVersionable({ + a: 1, + b: ['x', 'y', 'z'], + c: new Set(['x', 'y', 'z']), + d: { + x: 1, + y: { + e: 1, + y: 2, + }, + }, + e: [], + f: {}, + g: new Set(), + h: 'a', + i: [{ 'a': 1 }, 55], + j: new Set([{ 'b': 1 }]) as Set, + }); + memory.create('1'); + memory.switchTo('1'); + memory.attach(full); + + it('update object after switch without read values', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + + memory.create('test-a'); + memory.create('test-b'); + memory.switchTo('test-a'); + + full.d.y.e = 3; + full.d.y.y = 4; + + memory.create('test-a-1'); + memory.switchTo('test-b'); + + full.d.y.e = 5; + full.d.y.y = 6; + + memory.switchTo('test-a-1'); + + full.d.y.e = 7; + + expect(full.d.y).to.deep.equal({ + e: 7, + y: 4, + }); + }); + it('check if attribute in object without read values', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + memory.create('test-b'); + memory.create('test-a'); + memory.switchTo('test-a'); + + delete full.d.y.y; + + memory.create('test-a-1'); + memory.switchTo('test-b'); + + full.d.y.y = 6; + + memory.switchTo('test-a-1'); + + expect('y' in full.d.y).to.equal(false, 'after a change without read'); + + memory.switchTo('test-a'); + + expect('y' in full.d.y).to.equal( + false, + 'after swith check if memory writed on new slice', + ); + + memory.switchTo('test-b'); + + expect('y' in full.d.y).to.equal( + true, + 'check if the reload work for "in" instruction', + ); + }); + it('update array after switch without read values', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + + memory.create('test-a'); + memory.create('test-b'); + memory.switchTo('test-a'); + + const obj = new VersionableObject(); + full.e.push(obj, obj, obj); + + memory.create('test-a-1'); + memory.switchTo('test-b'); + + full.e.push(new VersionableArray()); + + memory.switchTo('test-a-1'); + + const set = new VersionableSet(); + full.e[1] = set; + + expect(full.e).to.deep.equal([obj, set, obj], 'after a change without read'); + + memory.switchTo('test-a'); + + expect(full.e).to.deep.equal( + [obj, obj, obj], + 'after swith check if memory writed on new slice', + ); + }); + it('update array length after switch without read values', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + + memory.create('test-a'); + memory.create('test-b'); + memory.switchTo('test-a'); + + const obj = new VersionableObject(); + const array = new VersionableArray(); + full.e.push(obj, array); + + memory.create('test-a-1'); + memory.switchTo('test-b'); + + full.e.push(33); + + memory.switchTo('test-a-1'); + + full.e.length = 1; + const set = makeVersionable({ a: 1 }); + full.e.push(set); + + expect(full.e).to.deep.equal([obj, set], 'after a change without read'); + + memory.switchTo('test-a'); + + expect(full.e).to.deep.equal( + [obj, array], + 'after swith check if memory writed on new slice', + ); + }); + it('update set after switch without read values', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + + memory.create('test-a'); + memory.create('test-b'); + memory.switchTo('test-a'); + + full.c.add('a'); + + memory.create('test-a-1'); + memory.switchTo('test-b'); + + full.c.delete('y'); + + memory.switchTo('test-a-1'); + + full.c.clear(); + + expect(full.c).to.deep.equal(new Set(), 'after a change without read'); + + memory.switchTo('test-a'); + + expect(full.c).to.deep.equal( + new Set(['x', 'y', 'z', 'a']), + 'after swith check if memory writed on new slice', + ); + }); + it('update set and call building methods', () => { + memory.switchTo('1'); + memory.remove('test'); // use remove before create to avoid previous test error propagation + memory.create('test'); + memory.switchTo('test'); + memory.create('test-b'); + memory.create('test-a'); + memory.switchTo('test-a'); + const set = full.j; + set.add(new VersionableObject()); + memory.switchTo('test-b'); + let iter = set.values().next(); + expect(iter.value).to.deep.equal({ 'b': 1 }); + memory.switchTo('test-a'); + memory.switchTo('test-b'); + iter = set.keys().next(); + expect(iter.value).to.deep.equal({ 'b': 1 }); + memory.switchTo('test-a'); + memory.switchTo('test-b'); + iter = set.entries().next(); + expect(iter.value).to.deep.equal([{ 'b': 1 }, { 'b': 1 }]); + }); + }); + + describe('memory slices', () => { + it('remove slice and children (check = no error)', () => { + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.create('test'); + memory.switchTo('test'); + + memory.create('test-0'); + memory.create('test-b-0'); + memory.switchTo('test-0'); + memory.create('test-c-0'); + + memory.switchTo('1'); + memory.remove('test'); + + memory.create('test-0'); + memory.create('test-b-0'); + memory.create('test-c-0'); + }); + it('remove slice avoid memory leak', () => { + const memory = new Memory(); + memory.create('1'); + memory.create('2'); + memory.create('3'); + const obj = new VersionableObject(); + + memory.switchTo('1'); + const array1 = new VersionableArray(); + memory.attach(array1); + + array1.push(obj); + + memory.switchTo('2'); + const array2 = new VersionableArray(); + memory.attach(array2); + + array2.push(obj); + + memory.switchTo('3'); + + expect(memory._proxies[obj[memoryProxyPramsKey].ID]).to.deep.equal(obj); + + memory.remove('1'); + + expect(memory._proxies[obj[memoryProxyPramsKey].ID]).to.deep.equal(obj); + + memory.remove('2'); + + expect(memory._proxies[obj[memoryProxyPramsKey].ID]).to.deep.equal(undefined); + }); + }); + + describe('snapshot', () => { + it('create a snapshot from 8 slices', () => { + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.create('test'); + memory.switchTo('test'); + for (let k = 0; k < 8; k++) { + memory.create('test-' + k); + memory.switchTo('test-' + k); + } + memory.create('other'); + memory.switchTo('other'); + expect(memory.getPath('other').join()).to.deep.equal( + 'other,test-7,test-6,test-5,test-4,test-3,test-2,test-1,test-0,test,1', + ); + memory.snapshot('test-0', 'test-7', 'snap'); + memory.switchTo('snap'); + memory.create('after-snap'); + + expect(memory.getPath('after-snap').join()).to.deep.equal( + 'after-snap,snap,test,1', + ); + expect(memory.getPath('after-snap', true).join()).to.deep.equal( + 'after-snap,snap,test-6,test-5,test-4,test-3,test-2,test-1,test-0,test,1', + ); + expect(memory.getPath('other').join()).to.deep.equal('other,snap,test,1'); + expect(memory.getPath('test-7').join()).to.deep.equal( + 'test-7,test-6,test-5,test-4,test-3,test-2,test-1,test-0,test,1', + ); + }); + it('remove slice and children after snapshot (check = no error)', () => { + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.create('test'); + memory.switchTo('test'); + for (let k = 0; k < 8; k++) { + memory.create('test-' + k); + memory.switchTo('test-' + k); + } + memory.create('other'); + memory.switchTo('other'); + memory.snapshot('test-0', 'test-7', 'snap'); + memory.switchTo('1'); + memory.remove('test'); + + // can re-create wihtout error (tested previously) + for (let k = 0; k < 8; k++) { + memory.create('test-' + k); + memory.switchTo('test-' + k); + } + memory.create('other'); + memory.switchTo('other'); + memory.snapshot('test-0', 'test-7', 'snap'); + }); + it('snapshot array changes', () => { + const memory = new Memory(); + memory._numberOfFlatSlices = 20; + memory._numberOfSlicePerSnapshot = 8; + + memory.create('1'); + memory.switchTo('1'); + const array = new VersionableArray(); + memory.attach(array); + array.push(0); + + memory.create('test'); + memory.switchTo('test'); + array.push(1); + memory.create('test-0'); + memory.switchTo('test-0'); + array.push(2); + memory.create('test-1'); + memory.switchTo('test-1'); + array.push(3); + memory.create('test-2'); + memory.switchTo('test-2'); + array.push(4); + + const ID = Object.keys(memory._slices.test.data)[0]; + let patch = memory._slices.test.data[ID]; + expect(Object.values(patch.patch)).to.deep.equal([1]); + + memory.snapshot('test', 'test-1', 'snap'); + + expect(Object.keys(memory._slices.snap.data).length).to.equal(1); + patch = memory._slices.snap.data[ID]; + expect(Object.values(patch.patch)).to.deep.equal([1, 2, 3]); + }); + it('snapshot set changes', () => { + const memory = new Memory(); + memory._numberOfFlatSlices = 20; + memory._numberOfSlicePerSnapshot = 8; + + memory.create('1'); + memory.switchTo('1'); + const set = new VersionableSet(); + memory.attach(set); + set.add(0); + set.add(-1); + + memory.create('test'); + memory.switchTo('test'); + set.delete(-1); + set.add(1); + set.add(2); + memory.create('test-0'); + memory.switchTo('test-0'); + set.delete(0); + memory.create('test-1'); + memory.switchTo('test-1'); + set.delete(2); + set.add(3); + set.add(-1); + memory.create('test-2'); + memory.switchTo('test-2'); + set.add(3); + + const ID = Object.keys(memory._slices.test.data)[0]; + let patch = memory._slices.test.data[ID]; + expect(patch.add.size).to.deep.equal(2); + expect(patch.delete.size).to.deep.equal(1); + + memory.snapshot('test', 'test-1', 'snap'); + + expect(Object.keys(memory._slices.snap.data).length).to.equal(1); + patch = memory._slices.snap.data[ID]; + + const add = []; + patch.add.forEach((item: number) => add.push(item)); + expect(add).to.deep.equal([1, 3]); + const remove = []; + patch.delete.forEach((item: number) => remove.push(item)); + expect(remove).to.deep.equal([0]); + }); + it('snapshot object changes', () => { + const memory = new Memory(); + memory._numberOfFlatSlices = 20; + memory._numberOfSlicePerSnapshot = 8; + + memory.create('1'); + memory.switchTo('1'); + const obj = new VersionableObject(); + memory.attach(obj); + obj['x+y'] = 1; + + memory.create('test'); + memory.switchTo('test'); + obj['a-z'] = new VersionableObject(); + const ID1 = obj['a-z'][memoryProxyPramsKey].ID; + obj['x+y'] = 2; + memory.create('test-0'); + memory.switchTo('test-0'); + obj['a+b'] = 0; + memory.create('test-1'); + memory.switchTo('test-1'); + obj['a-z'] = new VersionableArray(); + const ID2 = obj['a-z'][memoryProxyPramsKey].ID; + obj['a+b'] = 3; + obj['x+y'] = 1; + memory.create('test-2'); + memory.switchTo('test-2'); + obj['x+y'] = 10; + obj['w+u'] = 10; + + const ID = Object.keys(memory._slices.test.data)[0]; + let patch = memory._slices.test.data[ID]; + + expect(patch).to.deep.equal({ props: { 'a-z': ID1, 'x+y': 2 } }); + + memory.snapshot('test', 'test-1', 'snap'); + + expect(Object.keys(memory._slices.snap.data).length).to.equal(1); + patch = memory._slices.snap.data[ID]; + + expect(patch).to.deep.equal({ + props: { 'a-z': ID2, 'x+y': 1, 'a+b': 3 }, + }); + }); + it('auto-snapshot', () => { + const memory = new Memory(); + memory._numberOfFlatSlices = 20; + memory._numberOfSlicePerSnapshot = 8; + + const array = new VersionableArray(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(array); + + for (let k = 0; k < 100; k++) { + memory.create('test-' + k); + memory.switchTo('test-' + k); + array.push(k); + } + + const sliceKeys = Object.keys(memory._slices); + expect(sliceKeys.length).to.equal(1 + 100 + (100 - 20) / 8); + }); + it('compress array changes', () => { + const memory = new Memory(); + memory._numberOfFlatSlices = 20; + memory._numberOfSlicePerSnapshot = 8; + + memory.create('1'); + memory.switchTo('1'); + const array = new VersionableArray(); + memory.attach(array); + array.push(0); + + memory.create('test'); + memory.switchTo('test'); + array.push(1); + memory.create('test-0'); + memory.switchTo('test-0'); + array.push(2); + const obj = new VersionableObject(); + memory.attach(obj); + obj['x+y'] = 3; + memory.create('test-1'); + memory.switchTo('test-1'); + array.push(3); + memory.create('test-2'); + memory.switchTo('test-2'); + array.push(4); + + const ID = Object.keys(memory._slices.test.data)[0]; + let patch = memory._slices.test.data[ID]; + expect(Object.values(patch.patch)).to.deep.equal([1]); + expect(Object.keys(memory._slices).length).to.equal(6); + + memory.compress('test', 'test-1'); + + expect(Object.keys(memory._slices).length).to.equal(4); + expect(Object.keys(memory._slices.test.data).length).to.equal(2); + patch = memory._slices.test.data[ID]; + expect(Object.values(patch.patch)).to.deep.equal([1, 2, 3]); + patch = memory._slices.test.data[obj[memoryProxyPramsKey].ID]; + expect(patch).to.deep.equal({ props: { 'x+y': 3 } }); + }); + }); + + describe('root & path', () => { + it('link to memory & markAsDiffRoot', () => { + const obj = makeVersionable({ + toto: { + titi: { + m: 6, + }, + }, + tutu: { + a: 1, + }, + }); + markAsDiffRoot(obj.toto); + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(obj); + expect([...memory.getRoots(obj.toto.titi)]).to.deep.equal([obj.toto], 'titi'); + expect([...memory.getRoots(obj.toto)]).to.deep.equal([obj.toto], 'toto'); + expect([...memory.getRoots(obj.tutu)]).to.deep.equal([obj], 'tutu'); + }); + it('link to memory & markAsDiffRoot with change slice', () => { + const obj = makeVersionable({ + toto: { + titi: { + m: 6, + }, + tata: {}, + }, + tutu: { + a: 1, + }, + }); + markAsDiffRoot(obj.toto); + const memory = new Memory(); + memory.create('1'); + memory.switchTo('1'); + memory.attach(obj); + memory.create('2'); + memory.switchTo('2'); + obj.toto.tata = obj.tutu; + + expect(obj.toto.tata).to.deep.equal(obj.tutu); + + expect([...memory.getRoots(obj.toto.titi)]).to.deep.equal([obj.toto], 'titi'); + expect([...memory.getRoots(obj.toto)]).to.deep.equal([obj.toto], 'toto'); + const test1 = []; + memory.getRoots(obj.tutu).forEach(obj => test1.push(obj)); + expect(test1).to.deep.equal([obj.toto, obj], 'tutu'); + const test2 = []; + memory.getRoots(obj.toto.tata).forEach(obj => test2.push(obj)); + expect(test2).to.deep.equal([obj.toto, obj], 'tata'); + }); + it('should get the changed object', () => { + const tata = { + m: 6, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = makeVersionable({ + toto: { + titi: { + tata: tata, + }, + }, + tutu: { + a: 1, + tata: tata, + }, + vroum: { + b: 2, + }, + array: [ + { x: 1 }, + { x: 2 }, + { x: 3 }, + { x: 4 }, + { x: 5 }, + { x: 6 }, + { x: 7 }, + { x: 8 }, + ], + }); + markAsDiffRoot(obj.toto); + markAsDiffRoot(obj.tutu); + const memory = new Memory(); + memory.create('test'); + memory.switchTo('test'); + memory.attach(obj); + memory.create('test-1'); + memory.switchTo('test-1'); + + expect(memory.getChangesLocations('test', 'test-1')).to.deep.equal({ + add: [], + move: [], + remove: [], + update: [], + }); + + obj.toto.titi.tata.m = 4; + obj.tutu.a = 6; + const vroum = obj.vroum; + delete obj.vroum; + const x2 = obj.array[1]; + obj.array.splice(1, 1); + obj.array.splice(3, 0, makeVersionable({ z: 1 })); + obj.yop = makeVersionable({ a: 1 }); + + expect(memory.getChangesLocations('test', 'test-1')).to.deep.equal({ + add: [obj.array[3], obj.yop], + move: [], + remove: [vroum, x2], + update: [ + [obj, ['vroum', 'yop']], + [obj.tutu, ['a']], + [obj.array, [1, 3]], + [obj.toto.titi.tata, ['m']], + ], + }); + }); + }); + }); + }); +}); diff --git a/packages/core/src/Modifier.ts b/packages/core/src/Modifier.ts index 04abbe680..b252ef862 100644 --- a/packages/core/src/Modifier.ts +++ b/packages/core/src/Modifier.ts @@ -1,13 +1,11 @@ import { Constructor } from '../../utils/src/utils'; import { VNode } from './VNodes/VNode'; +import { VersionableObject } from './Memory/VersionableObject'; -export type ModifierTypeguard = ( - modifier: Modifier, - batch: VNode[], -) => modifier is T; +export type ModifierTypeguard = (modifier: Modifier) => modifier is T; export type ModifierPredicate = T extends Modifier ? Constructor | ModifierTypeguard - : (modifier: Modifier, batch: VNode[]) => boolean; + : (modifier: Modifier) => boolean; interface ModifierConstructor { new >(...args: ConstructorParameters): this; @@ -15,7 +13,7 @@ interface ModifierConstructor { export interface Modifier { constructor: ModifierConstructor & this; } -export class Modifier { +export class Modifier extends VersionableObject { preserve = true; get name(): string { return ''; diff --git a/packages/core/src/Modifiers.ts b/packages/core/src/Modifiers.ts index 866d9ce0b..6c225997f 100644 --- a/packages/core/src/Modifiers.ts +++ b/packages/core/src/Modifiers.ts @@ -1,9 +1,12 @@ import { Modifier } from './Modifier'; import { Constructor, isConstructor } from '../../utils/src/utils'; +import { VersionableObject } from './Memory/VersionableObject'; +import { VersionableArray } from './Memory/VersionableArray'; -export class Modifiers { +export class Modifiers extends VersionableObject { private _contents: Modifier[]; constructor(...modifiers: Array>) { + super(); const clonedModifiers = modifiers.map(mod => { return mod instanceof Modifier ? mod.clone() : mod; }); @@ -49,7 +52,7 @@ export class Modifiers { */ append(...modifiers: Array>): void { if (modifiers.length && !this._contents) { - this._contents = []; + this._contents = new VersionableArray(); } for (const modifier of modifiers) { if (modifier instanceof Modifier) { @@ -67,7 +70,7 @@ export class Modifiers { */ prepend(...modifiers: Array>): void { if (modifiers.length && !this._contents) { - this._contents = []; + this._contents = new VersionableArray(); } for (const modifier of [...modifiers].reverse()) { if (modifier instanceof Modifier) { diff --git a/packages/core/src/VNodes/AbstractNode.ts b/packages/core/src/VNodes/AbstractNode.ts index 109eb447c..3a9b28eae 100644 --- a/packages/core/src/VNodes/AbstractNode.ts +++ b/packages/core/src/VNodes/AbstractNode.ts @@ -1,4 +1,4 @@ -import { VNode, RelativePosition, Predicate, Typeguard, isLeaf } from './VNode'; +import { VNode, RelativePosition, Predicate, isLeaf } from './VNode'; import { Constructor, nodeLength, @@ -10,6 +10,7 @@ import { AtomicNode } from './AtomicNode'; import { Modifiers } from '../Modifiers'; import { EventMixin } from '../../../utils/src/EventMixin'; import { Modifier } from '../Modifier'; +import { markAsDiffRoot } from '../Memory/Memory'; export interface AbstractNodeParams { modifiers?: Modifiers | Array>; @@ -47,7 +48,7 @@ export abstract class AbstractNode extends EventMixin { * Can be overridden with a `Mode`. */ breakable = true; - parent: VNode; + parent: ContainerNode; modifiers = new Modifiers(); childVNodes: VNode[]; /** @@ -71,6 +72,7 @@ export abstract class AbstractNode extends EventMixin { this.modifiers.append(...params.modifiers); } } + markAsDiffRoot(this); } get name(): string { @@ -141,20 +143,6 @@ export abstract class AbstractNode extends EventMixin { get length(): number { return this.children().length; } - /** - * Return whether this node is an instance of the given VNode class. - * - * @param predicate The subclass of VNode to test this node against. - */ - is(predicate: Constructor | Typeguard): this is T; - is(predicate: Predicate): false; - is(predicate: Predicate): boolean { - if (AbstractNode.isConstructor(predicate)) { - return this instanceof predicate; - } else { - return predicate(this as VNode); - } - } /** * Test this node against the given predicate. * @@ -728,6 +716,7 @@ export abstract class AbstractNode extends EventMixin { return __repr; } } + export interface AbstractNode { constructor: new >(...args: ConstructorParameters) => this; } diff --git a/packages/core/src/VNodes/ContainerNode.ts b/packages/core/src/VNodes/ContainerNode.ts index 3edffa626..972604e69 100644 --- a/packages/core/src/VNodes/ContainerNode.ts +++ b/packages/core/src/VNodes/ContainerNode.ts @@ -1,10 +1,10 @@ import { AbstractNode } from './AbstractNode'; import { VNode, Predicate, isLeaf } from './VNode'; import { ChildError } from '../../../utils/src/errors'; +import { VersionableArray } from '../Memory/VersionableArray'; export class ContainerNode extends AbstractNode { - parent: ContainerNode; - readonly childVNodes: VNode[] = []; + readonly childVNodes: VNode[] = new VersionableArray(); //-------------------------------------------------------------------------- // Browsing children. @@ -16,15 +16,19 @@ export class ContainerNode extends AbstractNode { children(predicate?: Predicate): T[]; children(predicate?: Predicate): VNode[]; children(predicate?: Predicate): VNode[] { - return this.childVNodes.filter(child => { - return child.tangible && child.test(predicate); + const children: VNode[] = []; + this.childVNodes.forEach(child => { + if (child.tangible && (!predicate || child.test(predicate))) { + children.push(child); + } }); + return children; } /** * See {@link AbstractNode.hasChildren}. */ hasChildren(): boolean { - return this.children().length > 0; + return !!this.childVNodes.find(child => child.tangible); } /** * See {@link AbstractNode.nthChild}. @@ -39,7 +43,7 @@ export class ContainerNode extends AbstractNode { firstChild(predicate?: Predicate): VNode; firstChild(predicate?: Predicate): VNode { let child = this.childVNodes[0]; - while (child && !(child.tangible && child.test(predicate))) { + while (child && !(child.tangible && (!predicate || child.test(predicate)))) { child = child.nextSibling(); } return child; @@ -51,7 +55,7 @@ export class ContainerNode extends AbstractNode { lastChild(predicate?: Predicate): VNode; lastChild(predicate?: Predicate): VNode { let child = this.childVNodes[this.childVNodes.length - 1]; - while (child && !(child.tangible && child.test(predicate))) { + while (child && !(child.tangible && (!predicate || child.test(predicate)))) { child = child.previousSibling(); } return child; @@ -63,7 +67,7 @@ export class ContainerNode extends AbstractNode { firstLeaf(predicate?: Predicate): VNode; firstLeaf(predicate?: Predicate): VNode { const isValidLeaf = (node: VNode): boolean => { - return isLeaf(node) && node.test(predicate); + return isLeaf(node) && (!predicate || node.test(predicate)); }; if (isValidLeaf(this)) { return this; @@ -78,7 +82,7 @@ export class ContainerNode extends AbstractNode { lastLeaf(predicate?: Predicate): VNode; lastLeaf(predicate?: Predicate): VNode { const isValidLeaf = (node: VNode): boolean => { - return isLeaf(node) && node.test(predicate); + return isLeaf(node) && (!predicate || node.test(predicate)); }; if (isValidLeaf(this)) { return this; @@ -93,7 +97,7 @@ export class ContainerNode extends AbstractNode { firstDescendant(predicate?: Predicate): VNode; firstDescendant(predicate?: Predicate): VNode { let firstDescendant = this.firstChild(); - while (firstDescendant && !firstDescendant.test(predicate)) { + while (firstDescendant && predicate && !firstDescendant.test(predicate)) { firstDescendant = this._descendantAfter(firstDescendant); } return firstDescendant; @@ -108,7 +112,7 @@ export class ContainerNode extends AbstractNode { while (lastDescendant && lastDescendant.hasChildren()) { lastDescendant = lastDescendant.lastChild(); } - while (lastDescendant && !lastDescendant.test(predicate)) { + while (lastDescendant && predicate && !lastDescendant.test(predicate)) { lastDescendant = this._descendantBefore(lastDescendant); } return lastDescendant; @@ -123,7 +127,7 @@ export class ContainerNode extends AbstractNode { const stack = [...this.childVNodes]; while (stack.length) { const node = stack.shift(); - if (node.tangible && node.test(predicate)) { + if (node.tangible && (!predicate || node.test(predicate))) { descendants.push(node); } if (node instanceof ContainerNode) { @@ -226,9 +230,11 @@ export class ContainerNode extends AbstractNode { return this; } const duplicate = this.clone(); - const index = child.parent.childVNodes.indexOf(child); - while (this.childVNodes.length > index) { - duplicate.append(this.childVNodes[index]); + const index = this.childVNodes.indexOf(child); + const children = this.childVNodes.splice(index); + duplicate.childVNodes.push(...children); + for (const child of children) { + child.parent = duplicate; } this.after(duplicate); return duplicate; diff --git a/packages/core/src/VRange.ts b/packages/core/src/VRange.ts index 30aefab4d..a93df222a 100644 --- a/packages/core/src/VRange.ts +++ b/packages/core/src/VRange.ts @@ -240,7 +240,7 @@ export class VRange { } else if (position === RelativePosition.AFTER) { reference = reference.lastLeaf(); } - if (reference.is(ContainerNode) && !reference.hasChildren()) { + if (reference instanceof ContainerNode && !reference.hasChildren()) { reference.prepend(this.start); } else if (position === RelativePosition.AFTER && reference !== this.end) { // We check that `reference` isn't `this.end` to avoid a backward @@ -266,7 +266,7 @@ export class VRange { } else if (position === RelativePosition.AFTER) { reference = reference.lastLeaf(); } - if (reference.is(ContainerNode) && !reference.hasChildren()) { + if (reference instanceof ContainerNode && !reference.hasChildren()) { reference.append(this.end); } else if (position === RelativePosition.BEFORE && reference !== this.start) { // We check that `reference` isn't `this.start` to avoid a backward @@ -366,7 +366,7 @@ export class VRange { empty(): void { const removableNodes = this.selectedNodes(node => { // TODO: Replace Table check with complex table selection support. - return this.mode.is(node, RuleProperty.EDITABLE) && !node.is(TableCellNode); + return this.mode.is(node, RuleProperty.EDITABLE) && !(node instanceof TableCellNode); }); // Remove selected nodes without touching the start range's ancestors. const startAncestors = this.start.ancestors(); @@ -407,23 +407,3 @@ export class VRange { this.end.remove(); } } - -/** - * Create a temporary range corresponding to the given boundary points and - * call the given callback with the newly created range as argument. The - * range is automatically destroyed after calling the callback. - * - * @param bounds The points corresponding to the range boundaries. - * @param callback The callback to call with the newly created range. - */ -export async function withRange( - editor: JWEditor, - bounds: [Point, Point], - callback: (range: VRange) => T, - mode?: Mode, -): Promise { - const range = new VRange(editor, bounds, mode); - const result = await callback(range); - range.remove(); - return result; -} diff --git a/packages/core/test/ContextManager.test.ts b/packages/core/test/ContextManager.test.ts index 91062c081..20624fd26 100644 --- a/packages/core/test/ContextManager.test.ts +++ b/packages/core/test/ContextManager.test.ts @@ -288,16 +288,18 @@ describe('core', () => { contentBefore: '

cd

' + '
    • c

  • []
', - stepFunction: (editor: BasicEditor) => { + stepFunction: async (editor: BasicEditor) => { const callback = (): void => {}; const check = (context: CheckingContext): boolean => !!context; const checkSpy1 = sinon.spy(check); const checkSpy2 = sinon.spy(check); const newSelection = new VSelection(editor); - const domEngine = editor.plugins.get(Layout).engines.dom; - const editable = domEngine.components.get('editable')[0]; - newSelection.setAt(editable); + await editor.execCommand(async () => { + const domEngine = editor.plugins.get(Layout).engines.dom; + const editable = domEngine.components.get('editable')[0]; + newSelection.setAt(editable); + }); const commands: CommandImplementation[] = [ { title: 'paragraph', diff --git a/packages/core/test/JWeditor.test.ts b/packages/core/test/JWeditor.test.ts index 02b7a53c0..1fc5199ae 100644 --- a/packages/core/test/JWeditor.test.ts +++ b/packages/core/test/JWeditor.test.ts @@ -234,7 +234,7 @@ describe('core', () => { await testEditor(BasicEditor, { contentBefore: '
ab[]
', stepFunction: async editor => { - await editor.execCustomCommand(async () => { + await editor.execCommand(async () => { const layout = editor.plugins.get(Layout); const domEngine = layout.engines.dom; domEngine.components diff --git a/packages/core/test/MemoryVDocument.perf.ts b/packages/core/test/MemoryVDocument.perf.ts new file mode 100644 index 000000000..f1c269394 --- /dev/null +++ b/packages/core/test/MemoryVDocument.perf.ts @@ -0,0 +1,112 @@ +/* eslint-disable max-nested-callbacks */ +import { expect } from 'chai'; +import { BasicEditor } from '../../bundle-basic-editor/BasicEditor'; +import { DomEditable } from '../../plugin-dom-editable/src/DomEditable'; +import { DomLayout } from '../../plugin-dom-layout/src/DomLayout'; +import { Layout } from '../../plugin-layout/src/Layout'; +import { DomLayoutEngine } from '../../plugin-dom-layout/src/DomLayoutEngine'; + +describe('test performances', () => { + describe('stores', () => { + describe('Memory / VDocument', () => { + let wrapper: HTMLElement; + let editor: BasicEditor; + + beforeEach(async () => { + wrapper = document.createElement('test-wrapper'); + wrapper.style.display = 'block'; + document.body.appendChild(wrapper); + const root = document.createElement('div'); + root.innerHTML = `

Jabberwocky

+

by Lewis Carroll

+

’Twas brillig, and the slithy toves
+ Did gyre and gimble in the wabe:
+ All mimsy were the borogoves,
+ And the mome raths outgrabe.
+
+ “Beware the Jabberwock, my son!
+ The jaws that bite, the claws that catch!
+ Beware the Jubjub bird, and shun
+ The frumious Bandersnatch!”
+
+ He took his vorpal sword in hand;
+ Long time the manxome foe he sought—
+ So rested he by the Tumtum tree
+ And stood awhile in thought.
+
+ And, as in uffish thought he stood,
+ The Jabberwock, with eyes of flame,
+ Came whiffling through the tulgey wood,
+ And burbled as it came!
+
+ One, two! One, two! And through and through
+ The vorpal blade went snicker-snack!
+ He left it dead, and with its head
+ He went galumphing back.
+
+ “And hast thou slain the Jabberwock?
+ Come to my arms, my beamish boy!
+ O frabjous day! Callooh! Callay!”
+ He chortled in his joy.
+
+ ’Twas brillig, and the slithy toves
+ Did gyre and gimble in the wabe:
+ All mimsy were the borogoves,
+ And the mome raths outgrabe.

`; + wrapper.appendChild(root); + + editor = new BasicEditor(); + editor.configure(DomLayout, { location: [root, 'replace'] }); + editor.configure(DomEditable, { source: root }); + await editor.start(); + }); + afterEach(async () => { + editor.stop(); + document.body.removeChild(wrapper); + }); + + it('should split a paragraph in two', async () => { + // Parse the editable in the internal format of the editor. + const memory = editor.memory; + const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; + const editable = domEngine.components.get('editable')[0]; + editor.selection.setAt(editable.children()[2].children()[500]); + + memory.attach(editable); + memory.create('root').switchTo('root'); + + expect(editable.children().length).to.equal(3); + memory.create('test').switchTo('test'); + await editor.execCommand('insertParagraphBreak'); + expect(editable.children().length).to.equal(4); + + const t1 = []; + const t2 = []; + for (let k = 2; k < 26; k++) { + let d = Date.now(); + memory + .switchTo('root') + .create(k.toString()) + .switchTo(k.toString()); + t1.push(Date.now() - d); + + d = Date.now(); + await editor.execCommand('insertParagraphBreak'); + t2.push(Date.now() - d); + } + + // We remove the first load because it does not represent time in + // use. In fact, time is much longer because the functions and + // object are not yet loaded. The loading test is done separately. + t1.shift(); + t2.shift(); + + const averageInsert = Math.round(t2.reduce((a, b) => a + b) / t2.length); + expect(averageInsert).to.lessThan(30, 'Time to compute the insert paragraph'); + + const averageSwitch = Math.round(t1.reduce((a, b) => a + b) / t1.length); + expect(averageSwitch).to.lessThan(1, 'Time to switch the memory'); + }); + }); + }); +}); diff --git a/packages/core/test/Mode.test.ts b/packages/core/test/Mode.test.ts index 5b1d7ba6a..1749a4c93 100644 --- a/packages/core/test/Mode.test.ts +++ b/packages/core/test/Mode.test.ts @@ -2,7 +2,7 @@ import JWEditor from '../src/JWEditor'; import { ContainerNode } from '../src/VNodes/ContainerNode'; import { expect } from 'chai'; import { ModeDefinition, Mode, RuleProperty } from '../src/Mode'; -import { withRange, VRange } from '../src/VRange'; +import { VRange } from '../src/VRange'; describe('core', () => { describe('Modes', () => { @@ -66,12 +66,11 @@ describe('core', () => { ], }); // With default mode. - await withRange(editor, VRange.at(root), async range => { + await editor.withRange(VRange.at(root), async range => { expect(range.mode.is(range.startContainer, RuleProperty.EDITABLE)).to.be.true; }); // With special mode - await withRange( - editor, + await editor.withRange( VRange.at(root), async range => { expect(range.mode.is(range.startContainer, RuleProperty.EDITABLE)).to.be diff --git a/packages/core/test/VDocument.test.ts b/packages/core/test/VDocument.test.ts index b130c552e..5e442fa11 100644 --- a/packages/core/test/VDocument.test.ts +++ b/packages/core/test/VDocument.test.ts @@ -1,14 +1,15 @@ import JWEditor from '../src/JWEditor'; import { testEditor } from '../../utils/src/testUtils'; import { Char } from '../../plugin-char/src/Char'; -import { VRange, withRange } from '../src/VRange'; +import { VRange } from '../src/VRange'; import { RelativePosition, Point } from '../src/VNodes/VNode'; import { BasicEditor } from '../../bundle-basic-editor/BasicEditor'; import { Core } from '../../core/src/Core'; import { Layout } from '../../plugin-layout/src/Layout'; -const deleteForward = async (editor: JWEditor): Promise => +const deleteForward = async (editor: JWEditor): Promise => { await editor.execCommand('deleteForward'); +}; const deleteBackward = async (editor: JWEditor): Promise => await editor.execCommand('deleteBackward'); const insertParagraphBreak = async (editor: JWEditor): Promise => @@ -555,9 +556,11 @@ describe('VDocument', () => { stepFunction: (editor: JWEditor) => { const domEngine = editor.plugins.get(Layout).engines.dom; const editable = domEngine.components.get('editable')[0]; - editable.firstChild().breakable = false; - editable.lastChild().breakable = false; - return deleteForward(editor); + return editor.execCommand(() => { + editable.firstChild().breakable = false; + editable.lastChild().breakable = false; + return deleteForward(editor); + }); }, contentAfter: '

a[

]f

', }); @@ -1523,7 +1526,7 @@ describe('VDocument', () => { const domEngine = editor.plugins.get(Layout).engines.dom; const editable = domEngine.components.get('editable')[0]; const aNode = editable.next(node => node.name === 'a'); - await withRange(editor, VRange.at(aNode), async range => { + await editor.withRange(VRange.at(aNode), async range => { await editor.execCommand('insertText', { text: 'c', context: { @@ -1542,7 +1545,7 @@ describe('VDocument', () => { const domEngine = editor.plugins.get(Layout).engines.dom; const editable = domEngine.components.get('editable')[0]; const aNode = editable.next(node => node.name === 'a'); - await withRange(editor, VRange.selecting(aNode, aNode), async range => { + await editor.withRange(VRange.selecting(aNode, aNode), async range => { await editor.execCommand('insertText', { text: 'c', context: { @@ -1565,7 +1568,7 @@ describe('VDocument', () => { [aNode, RelativePosition.BEFORE], [aNode, RelativePosition.BEFORE], ]; - await withRange(editor, rangeParams, async range => { + await editor.withRange(rangeParams, async range => { await editor.execCommand('insertText', { text: 'c', context: { @@ -1588,7 +1591,7 @@ describe('VDocument', () => { [aNode, RelativePosition.BEFORE], [aNode, RelativePosition.AFTER], ]; - await withRange(editor, rangeParams, async range => { + await editor.withRange(rangeParams, async range => { await editor.execCommand('insertText', { text: 'c', context: { @@ -1611,7 +1614,7 @@ describe('VDocument', () => { [aNode, RelativePosition.AFTER], [aNode, RelativePosition.AFTER], ]; - await withRange(editor, rangeParams, async range => { + await editor.withRange(rangeParams, async range => { await editor.execCommand('insertText', { text: 'c', context: { @@ -1635,7 +1638,7 @@ describe('VDocument', () => { [aNode, RelativePosition.AFTER], [bNode, RelativePosition.BEFORE], ]; - await withRange(editor, rangeParams, async range => { + await editor.withRange(rangeParams, async range => { await editor.execCommand('insertText', { text: 'c', context: { @@ -1659,7 +1662,7 @@ describe('VDocument', () => { [aNode, RelativePosition.AFTER], [bNode, RelativePosition.AFTER], ]; - await withRange(editor, rangeParams, async range => { + await editor.withRange(rangeParams, async range => { await editor.execCommand('insertText', { text: 'c', context: { diff --git a/packages/core/test/VNodes.test.ts b/packages/core/test/VNodes.test.ts index 898fc25b0..a061fdb44 100644 --- a/packages/core/test/VNodes.test.ts +++ b/packages/core/test/VNodes.test.ts @@ -30,7 +30,7 @@ describe('core', () => { it('should create an unknown element', async () => { for (let i = 1; i <= 6; i++) { const vNode = new VElement({ htmlTag: 'UNKNOWN-ELEMENT' }); - expect(vNode.is(AtomicNode)).to.equal(false); + expect(vNode instanceof AtomicNode).to.equal(false); expect(vNode.htmlTag).to.equal('UNKNOWN-ELEMENT'); } }); @@ -49,7 +49,7 @@ describe('core', () => { it('should create a marker node', async () => { const markerNode = new MarkerNode(); expect(markerNode.tangible).to.equal(false); - expect(markerNode.is(AtomicNode)).to.equal(true); + expect(markerNode instanceof AtomicNode).to.equal(true); }); }); }); @@ -95,11 +95,11 @@ describe('core', () => { describe('constructor', () => { it('should create an AtomicNode', async () => { const atomic = new AtomicNode(); - expect(atomic.is(AtomicNode)).to.equal(true); + expect(atomic instanceof AtomicNode).to.equal(true); }); it('should create a ContainerNode', async () => { const container = new ContainerNode(); - expect(container.is(AtomicNode)).to.equal(false); + expect(container instanceof AtomicNode).to.equal(false); }); }); describe('toString', () => { @@ -930,7 +930,9 @@ describe('core', () => { const editable = domEngine.components.get('editable')[0]; const a = editable.firstLeaf(); const ancestors = a.ancestors(ancestor => { - return !ancestor.is(HeadingNode) || ancestor.level !== 1; + return ( + !(ancestor instanceof HeadingNode) || ancestor.level !== 1 + ); }); expect(ancestors.map(ancestor => ancestor.name)).to.deep.equal([ 'ParagraphNode', @@ -973,7 +975,8 @@ describe('core', () => { const editable = domEngine.components.get('editable')[0]; const descendants = editable.descendants( descendant => - !descendant.is(HeadingNode) || descendant.level !== 2, + !(descendant instanceof HeadingNode) || + descendant.level !== 2, ); expect( descendants.map(descendant => descendant.name), diff --git a/packages/core/test/VRange.test.ts b/packages/core/test/VRange.test.ts index 6b73f1e34..304061b0c 100644 --- a/packages/core/test/VRange.test.ts +++ b/packages/core/test/VRange.test.ts @@ -9,10 +9,12 @@ describe('VRange', () => { contentBefore: 'abcde[fghijklmnopqrstuvwxy]z', stepFunction: async (editor: BasicEditor) => { editor.dispatcher.registerCommand('refresh', { handler: () => {} }); - const nodes = editor.selection.range.split(); - const domEngine = editor.plugins.get(Layout).engines.dom; - const editable = domEngine.components.get('editable')[0]; - editable.lastChild().after(nodes[0]); + await editor.execCommand(async () => { + const nodes = editor.selection.range.split(); + const domEngine = editor.plugins.get(Layout).engines.dom; + const editable = domEngine.components.get('editable')[0]; + editable.lastChild().after(nodes[0]); + }); await editor.execCommand('refresh'); }, contentAfter: diff --git a/packages/plugin-align/test/Align.test.ts b/packages/plugin-align/test/Align.test.ts index 98c389500..6a098a576 100644 --- a/packages/plugin-align/test/Align.test.ts +++ b/packages/plugin-align/test/Align.test.ts @@ -40,8 +40,10 @@ describePlugin(Align, testEditor => { const domEngine = editor.plugins.get(Layout).engines.dom; const editable = domEngine.components.get('editable')[0]; const root = editable; - root.lastChild().editable = false; - return align(AlignType.LEFT)(editor); + return editor.execCommand(() => { + root.lastChild().editable = false; + return align(AlignType.LEFT)(editor); + }); }, contentAfter: '

ab

c[]d

', }); diff --git a/packages/plugin-blockquote/src/BlockquoteXmlDomParser.ts b/packages/plugin-blockquote/src/BlockquoteXmlDomParser.ts index c4e6c64f8..311e3b686 100644 --- a/packages/plugin-blockquote/src/BlockquoteXmlDomParser.ts +++ b/packages/plugin-blockquote/src/BlockquoteXmlDomParser.ts @@ -13,7 +13,10 @@ export class BlockquoteXmlDomParser extends AbstractParser { async parse(item: Element): Promise { const blockquote = new BlockquoteNode(); - blockquote.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + blockquote.modifiers.append(attributes); + } const nodes = await this.engine.parse(...item.childNodes); blockquote.append(...nodes); return [blockquote]; diff --git a/packages/plugin-bold/src/BoldXmlDomParser.ts b/packages/plugin-bold/src/BoldXmlDomParser.ts index 63cc5352d..dc4b7631c 100644 --- a/packages/plugin-bold/src/BoldXmlDomParser.ts +++ b/packages/plugin-bold/src/BoldXmlDomParser.ts @@ -15,7 +15,10 @@ export class BoldXmlDomParser extends FormatXmlDomParser { */ async parse(item: Element): Promise { const bold = new BoldFormat(nodeName(item) as 'B' | 'STRONG'); - bold.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + bold.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); this.applyFormat(bold, children); diff --git a/packages/plugin-char/src/Char.ts b/packages/plugin-char/src/Char.ts index ebf028a86..a36319208 100644 --- a/packages/plugin-char/src/Char.ts +++ b/packages/plugin-char/src/Char.ts @@ -50,10 +50,18 @@ export class Char extends JWPlugin let modifiers = inline.getCurrentModifiers(range); // Ony preserved modifiers are applied at the start of a container. const previousSibling = range.start.previousSibling(); - if (!previousSibling) { - modifiers = new Modifiers(...modifiers.filter(mod => mod.preserve)); + if (!previousSibling && modifiers) { + const preservedModifiers = modifiers.filter(mod => mod.preserve); + if (preservedModifiers.length) { + modifiers = new Modifiers(...preservedModifiers); + } else { + modifiers = null; + } } if (params.formats) { + if (!modifiers) { + modifiers = new Modifiers(); + } modifiers.set(...params.formats.map(format => format.clone())); } const style = inline.getCurrentStyle(range); @@ -64,10 +72,14 @@ export class Char extends JWPlugin // Split the text into CHAR nodes and insert them at the range. const characters = text.split(''); const charNodes = characters.map(char => { - return new CharNode({ char: char, modifiers: modifiers.clone() }); + if (modifiers) { + return new CharNode({ char: char, modifiers: modifiers.clone() }); + } else { + return new CharNode({ char: char }); + } }); charNodes.forEach(charNode => { - if (style.length) { + if (style?.length) { charNode.modifiers.get(Attributes).style = style; } range.start.before(charNode); @@ -75,6 +87,5 @@ export class Char extends JWPlugin if (params.select && charNodes.length) { this.editor.selection.select(charNodes[0], charNodes[charNodes.length - 1]); } - inline.resetCache(); } } diff --git a/packages/plugin-char/src/CharDomObjectRenderer.ts b/packages/plugin-char/src/CharDomObjectRenderer.ts index c356feb42..800875cde 100644 --- a/packages/plugin-char/src/CharDomObjectRenderer.ts +++ b/packages/plugin-char/src/CharDomObjectRenderer.ts @@ -6,22 +6,29 @@ import { } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; import { NodeRenderer } from '../../plugin-renderer/src/NodeRenderer'; import { Predicate } from '../../core/src/VNodes/VNode'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class CharDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; engine: DomObjectRenderingEngine; predicate: Predicate = CharNode; - async render(charNode: CharNode): Promise { - return this._renderText([charNode]); + async render(charNode: CharNode, worker: RenderingEngineWorker): Promise { + return this._renderText([charNode], worker); } - async renderBatch(charNodes: CharNode[]): Promise { + async renderBatch( + charNodes: CharNode[], + worker: RenderingEngineWorker, + ): Promise { const domObjects: DomObject[] = []; - const domObject = this._renderText(charNodes); + const domObject = this._renderText(charNodes, worker); for (let i = 0; i < charNodes.length; i++) domObjects.push(domObject); return domObjects; } - private _renderText(charNodes: CharNode[]): DomObject { + private _renderText( + charNodes: CharNode[], + worker: RenderingEngineWorker, + ): DomObject { // Create textObject. const texts = []; for (const charNode of charNodes) { @@ -36,15 +43,15 @@ export class CharDomObjectRenderer extends NodeRenderer { // Render block edge spaces as non-breakable space (otherwise browsers // won't render them). const previous = charNodes[0].previousSibling(); - if (!previous || !previous.is(InlineNode)) { + if (!previous || !(previous instanceof InlineNode)) { texts[0] = texts[0].replace(/^ /g, '\u00A0'); } const next = charNodes[charNodes.length - 1].nextSibling(); - if (!next || !next.is(InlineNode)) { + if (!next || !(next instanceof InlineNode)) { texts[texts.length - 1] = texts[texts.length - 1].replace(/^ /g, '\u00A0'); } const textObject = { text: texts.join('') }; - this.engine.locate(charNodes, textObject); + worker.locate(charNodes, textObject); return textObject; } } diff --git a/packages/plugin-char/test/Char.test.ts b/packages/plugin-char/test/Char.test.ts index 71ae26d2a..6de1c4d52 100644 --- a/packages/plugin-char/test/Char.test.ts +++ b/packages/plugin-char/test/Char.test.ts @@ -60,14 +60,14 @@ describePlugin(Char, testEditor => { it('should create a CharNode', async () => { const c = new CharNode({ char: ' ' }); expect(c.char).to.equal(' '); - expect(c.is(AtomicNode)).to.equal(true); + expect(c instanceof AtomicNode).to.equal(true); expect(c.modifiers.length).to.equal(0); expect(c.length).to.equal(1); }); it('should create a CharNode with format', async () => { const c = new CharNode({ char: ' ', modifiers: new Modifiers(BoldFormat) }); expect(c.char).to.equal(' '); - expect(c.is(AtomicNode)).to.equal(true); + expect(c instanceof AtomicNode).to.equal(true); expect(c.modifiers.length).to.equal(1); expect(c.modifiers.map(m => m.constructor.name)).to.deep.equal(['BoldFormat']); }); diff --git a/packages/plugin-char/test/CharDomObjectRenderer.test.ts b/packages/plugin-char/test/CharDomObjectRenderer.test.ts index a637f02f8..4db80fd06 100644 --- a/packages/plugin-char/test/CharDomObjectRenderer.test.ts +++ b/packages/plugin-char/test/CharDomObjectRenderer.test.ts @@ -4,7 +4,10 @@ import { Char } from '../src/Char'; import { CharNode } from '../src/CharNode'; import { Renderer } from '../../plugin-renderer/src/Renderer'; import { ContainerNode } from '../../core/src/VNodes/ContainerNode'; -import { DomObject } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; +import { + DomObject, + DomObjectRenderingEngine, +} from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; import { DomLayout } from '../../plugin-dom-layout/src/DomLayout'; import { VNode } from '../../core/src/VNodes/VNode'; @@ -30,13 +33,12 @@ describe('CharDomObjectRenderer', () => { root.append(new CharNode({ char: 'b' })); const renderer = editor.plugins.get(Renderer); - const rendered = await renderer.render('dom/object', char); + const engine = renderer.engines['dom/object'] as DomObjectRenderingEngine; + const cache = await engine.render(root.childVNodes); + const rendered = cache.renderings.get(char); expect(rendered).to.deep.equal({ text: 'a \u00A0b' }); - const locations = renderer.engines['dom/object'].locations as Map< - DomObject, - VNode[] - >; + const locations = cache.locations as Map; expect(rendered && locations.get(rendered)).to.deep.equal(root.childVNodes); }); it('should insert 2 spaces and 2 nbsp instead of 4 spaces', async () => { @@ -50,13 +52,12 @@ describe('CharDomObjectRenderer', () => { root.append(new CharNode({ char: 'b' })); const renderer = editor.plugins.get(Renderer); - const rendered = await renderer.render('dom/object', char); + const engine = renderer.engines['dom/object'] as DomObjectRenderingEngine; + const cache = await engine.render(root.childVNodes); + const rendered = cache.renderings.get(char); expect(rendered).to.deep.equal({ text: 'a \u00A0 \u00A0b' }); - const locations = renderer.engines['dom/object'].locations as Map< - DomObject, - VNode[] - >; + const locations = cache.locations as Map; expect(rendered && locations.get(rendered)).to.deep.equal(root.children()); }); }); diff --git a/packages/plugin-color/src/Color.ts b/packages/plugin-color/src/Color.ts index cc3de883b..a44e43c89 100644 --- a/packages/plugin-color/src/Color.ts +++ b/packages/plugin-color/src/Color.ts @@ -109,7 +109,7 @@ export class Color extends JWPlugin { // Uncolor the children and their formats as well. for (const child of node.children()) { child.modifiers.find(Attributes)?.style.remove(this.styleName); - if (child.is(InlineNode)) { + if (child instanceof InlineNode) { for (const format of child.modifiers.filter(Format)) { format.modifiers.find(Attributes)?.style.remove(this.styleName); } diff --git a/packages/plugin-devtools/src/components/DevToolsComponent.ts b/packages/plugin-devtools/src/components/DevToolsComponent.ts index a97bc4571..ccfc8b84b 100644 --- a/packages/plugin-devtools/src/components/DevToolsComponent.ts +++ b/packages/plugin-devtools/src/components/DevToolsComponent.ts @@ -39,7 +39,8 @@ export class DevToolsComponent extends OwlComponent { _heightOnLastMousedown: number; async willStart(): Promise { - this.env.editor.dispatcher.registerCommandHook('*', this.refresh.bind(this)); + this.env.editor.dispatcher.registerCommandHook('*', this.addCommand.bind(this)); + this.env.editor.dispatcher.registerCommandHook('@commit', this.render.bind(this)); return super.willStart(); } willUnmount(): void { @@ -69,12 +70,10 @@ export class DevToolsComponent extends OwlComponent { (this.inspectorRef.comp as InspectorComponent)?.inspectDom(); } /** - * Refresh this component with respect to the recent dispatching of the - * given command with the given arguments. + * Add the recent dispatching of the given command with the given arguments. */ - refresh(params: CommandParams, id: CommandIdentifier): void { + addCommand(params: CommandParams, id: CommandIdentifier): void { this.state.commands.push([id, params]); - this.render(); } /** * Drag the DevTools to resize them diff --git a/packages/plugin-devtools/src/components/InfoComponent.ts b/packages/plugin-devtools/src/components/InfoComponent.ts index 440076c30..ee3481aef 100644 --- a/packages/plugin-devtools/src/components/InfoComponent.ts +++ b/packages/plugin-devtools/src/components/InfoComponent.ts @@ -50,9 +50,9 @@ export class InfoComponent extends OwlComponent<{}> { propRepr(vNode: VNode, propName: string): string { let prop = vNode[propName]; if (propName === 'atomic') { - if (vNode.is(AtomicNode)) { + if (vNode instanceof AtomicNode) { return 'true'; - } else if (vNode.is(ContainerNode)) { + } else if (vNode instanceof ContainerNode) { return 'false'; } else { return '?'; diff --git a/packages/plugin-devtools/src/components/InspectorComponent.ts b/packages/plugin-devtools/src/components/InspectorComponent.ts index 3a4b72de0..49b616602 100644 --- a/packages/plugin-devtools/src/components/InspectorComponent.ts +++ b/packages/plugin-devtools/src/components/InspectorComponent.ts @@ -7,6 +7,7 @@ import { Layout } from '../../../plugin-layout/src/Layout'; import { DomLayoutEngine } from '../../../plugin-dom-layout/src/DomLayoutEngine'; import { caretPositionFromPoint } from '../../../utils/src/polyfill'; import { CharNode } from '../../../plugin-char/src/CharNode'; +import { nodeName } from '../../../utils/src/utils'; const hoverStyle = 'box-shadow: inset 0 0 0 100vh rgba(95, 146, 204, 0.5); cursor: pointer;'; @@ -31,9 +32,11 @@ export class InspectorComponent extends OwlComponent { selectedID: this.domEngine.components.get('editable')[0]?.id || this.domEngine.root.id, }; selectedNode = this.getNode(this.state.selectedID); + private _inspecting = new Set(); constructor(parent?: OwlComponent<{}>, props?: InspectorComponentProps) { super(parent, props); + this._onInspectorMouseEnter = this._onInspectorMouseEnter.bind(this); this._onInspectorMouseMove = this._onInspectorMouseMove.bind(this); this._onInspectorMouseLeave = this._onInspectorMouseLeave.bind(this); this._onInspectorMouseDown = this._onInspectorMouseDown.bind(this); @@ -126,17 +129,32 @@ export class InspectorComponent extends OwlComponent { * * @param ev */ - inspectDom(): void { - window.addEventListener('mousemove', this._onInspectorMouseMove, true); - window.addEventListener('mouseleave', this._onInspectorMouseLeave, true); - window.addEventListener('mousedown', this._onInspectorMouseDown, true); - window.addEventListener('click', this._onInspectorClick, true); + inspectDom(doc: Document | ShadowRoot = document): void { + if (!this._inspecting.has(doc)) { + this._inspecting.add(doc); + doc.addEventListener('mouseenter', this._onInspectorMouseEnter, true); + doc.addEventListener('mousemove', this._onInspectorMouseMove, true); + doc.addEventListener('mouseleave', this._onInspectorMouseLeave, true); + doc.addEventListener('mousedown', this._onInspectorMouseDown, true); + doc.addEventListener('click', this._onInspectorClick, true); + } } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- + private async _onInspectorMouseEnter(ev: MouseEvent): Promise { + const el = ev.target as Element; + if (nodeName(el) === 'IFRAME') { + const doc = (ev.target as HTMLIFrameElement).contentWindow?.document; + if (doc) { + this.inspectDom(doc); + } + } else if (el.shadowRoot) { + this.inspectDom(el.shadowRoot); + } + } /** * Add a class to highlight the targeted node (like to VNode). * @@ -151,7 +169,11 @@ export class InspectorComponent extends OwlComponent { this._hoveredTargets = []; const elements: HTMLElement[] = []; - for (const node of this._getNodeFromPosition(ev.clientX, ev.clientY)) { + for (const node of this._getNodeFromPosition( + ev.clientX, + ev.clientY, + (ev.target as Node).ownerDocument, + )) { for (const domNode of this.domEngine.getDomNodes(node)) { const element = domNode instanceof HTMLElement ? domNode : domNode.parentElement; if (!elements.includes(element)) { @@ -192,15 +214,15 @@ export class InspectorComponent extends OwlComponent { ev.stopImmediatePropagation(); ev.preventDefault(); } - private _getNodeFromPosition(clientX: number, clientY: number): VNode[] { - const caretPosition = caretPositionFromPoint(clientX, clientY); + private _getNodeFromPosition(clientX: number, clientY: number, doc: Document): VNode[] { + const caretPosition = caretPositionFromPoint(clientX, clientY, doc); let node = caretPosition?.offsetNode; let nodes = []; while (!nodes.length && node) { nodes = this.domEngine.getNodes(node); node = node.parentNode; } - if (nodes.length && nodes[0].is(CharNode) && nodes[caretPosition.offset]) { + if (nodes[0] instanceof CharNode && nodes[caretPosition.offset]) { return [nodes[caretPosition.offset]]; } return nodes; @@ -211,10 +233,14 @@ export class InspectorComponent extends OwlComponent { * @param ev */ private async _onInspectorClick(ev: MouseEvent): Promise { - window.removeEventListener('mousemove', this._onInspectorMouseMove, true); - window.removeEventListener('mouseleave', this._onInspectorMouseLeave, true); - window.removeEventListener('mousedown', this._onInspectorMouseDown, true); - window.removeEventListener('click', this._onInspectorClick, true); + for (const doc of this._inspecting) { + doc.removeEventListener('mouseenter', this._onInspectorMouseEnter, true); + doc.removeEventListener('mousemove', this._onInspectorMouseMove, true); + doc.removeEventListener('mouseleave', this._onInspectorMouseLeave, true); + doc.removeEventListener('mousedown', this._onInspectorMouseDown, true); + doc.removeEventListener('click', this._onInspectorClick, true); + } + this._inspecting.clear(); ev.stopImmediatePropagation(); ev.preventDefault(); for (const inspected of this._hoveredTargets) { @@ -222,7 +248,11 @@ export class InspectorComponent extends OwlComponent { } this._hoveredTargets = []; - const nodes = this._getNodeFromPosition(ev.clientX, ev.clientY); + const nodes = this._getNodeFromPosition( + ev.clientX, + ev.clientY, + (ev.target as Node).ownerDocument, + ); if (nodes.length) { this.state.selectedID = nodes[0].id; this.selectedNode = this.getNode(this.state.selectedID); diff --git a/packages/plugin-devtools/src/components/TreeComponent.ts b/packages/plugin-devtools/src/components/TreeComponent.ts index 1e4a4e12e..9d361602b 100644 --- a/packages/plugin-devtools/src/components/TreeComponent.ts +++ b/packages/plugin-devtools/src/components/TreeComponent.ts @@ -126,7 +126,10 @@ export class TreeComponent extends OwlComponent { * @param node */ isFormat(node: VNode, formatName: string): boolean { - return node.is(InlineNode) && !!node.modifiers.find(format => format.name === formatName); + return ( + node instanceof InlineNode && + !!node.modifiers.find(format => format.name === formatName) + ); } //-------------------------------------------------------------------------- @@ -156,7 +159,7 @@ export class TreeComponent extends OwlComponent { */ _getSelectionMarkersAncestors(): Set { const selectionMarkersAncestors = new Set(); - let ancestor = this.env.editor.selection.anchor.parent; + let ancestor: VNode = this.env.editor.selection.anchor.parent; while (ancestor) { selectionMarkersAncestors.add(ancestor.id); ancestor = ancestor.parent; diff --git a/packages/plugin-devtools/test/devtools.test.ts b/packages/plugin-devtools/test/devtools.test.ts index eb0ebe856..0404d7457 100644 --- a/packages/plugin-devtools/test/devtools.test.ts +++ b/packages/plugin-devtools/test/devtools.test.ts @@ -302,7 +302,7 @@ describe('Plugin: DevTools', () => { '"' + 'length3' + 'atomicfalse' + - 'modifiers[ Attributes: {} ]' + + 'modifiers[]' + 'total length3' + 'text content' + root.children()[1].textContent + @@ -906,7 +906,7 @@ describe('Plugin: DevTools', () => { [...subpanel.querySelectorAll('devtools-td:not(.numbering)')].map( td => td.textContent, ), - ).to.deep.equal(['insertText', 'setSelection']); + ).to.deep.equal(['@commit', 'insertText', '@commit', 'setSelection']); }); it('should select "hide"', async () => { await openDevTools(); @@ -1092,7 +1092,7 @@ describe('Plugin: DevTools', () => { const subpanel = wrapper .querySelector('jw-devtools') .querySelector('devtools-panel.active mainpane-contents'); - const line = subpanel.querySelector('.selectable-line'); + const line = subpanel.querySelector('.selectable-line:nth-child(2)'); await click(line); expect(line.classList.contains('selected')).to.equal(true); @@ -1107,7 +1107,7 @@ describe('Plugin: DevTools', () => { const subpanel = wrapper .querySelector('jw-devtools') .querySelector('devtools-panel.active mainpane-contents'); - const line = subpanel.querySelector('.selectable-line:nth-child(2)'); + const line = subpanel.querySelector('.selectable-line:nth-child(4)'); await click(line); expect(line.classList.contains('selected')).to.equal(true); @@ -1141,7 +1141,7 @@ describe('Plugin: DevTools', () => { const subpanel = wrapper .querySelector('jw-devtools') .querySelector('devtools-panel.active mainpane-contents'); - const line = subpanel.querySelector('.selectable-line:nth-child(2)'); + const line = subpanel.querySelector('.selectable-line:nth-child(3)'); await click(line); await keydown(line, 'ArrowUp'); @@ -1169,7 +1169,7 @@ describe('Plugin: DevTools', () => { const subpanel = wrapper .querySelector('jw-devtools') .querySelector('devtools-panel.active mainpane-contents'); - const line = subpanel.querySelector('.selectable-line'); + const line = subpanel.querySelector('.selectable-line:nth-child(3)'); await click(line); await keydown(line, 'ArrowDown'); diff --git a/packages/plugin-dialog/src/DialogZoneDomObjectRenderer.ts b/packages/plugin-dialog/src/DialogZoneDomObjectRenderer.ts index 42b13bf99..e5bf71a5f 100644 --- a/packages/plugin-dialog/src/DialogZoneDomObjectRenderer.ts +++ b/packages/plugin-dialog/src/DialogZoneDomObjectRenderer.ts @@ -11,6 +11,7 @@ import { MetadataNode } from '../../plugin-metadata/src/MetadataNode'; import template from '../assets/Dialog.xml'; import '../assets/Dialog.css'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; const container = document.createElement('jw-container'); container.innerHTML = template; @@ -21,11 +22,17 @@ export class DialogZoneDomObjectRenderer extends NodeRenderer { engine: DomObjectRenderingEngine; predicate = DialogZoneNode; - async render(node: DialogZoneNode): Promise { + async render( + node: DialogZoneNode, + worker: RenderingEngineWorker, + ): Promise { const float = document.createElement('jw-dialog-container'); for (const child of node.childVNodes) { - if (!node.hidden.get(child) && (child.tangible || child.is(MetadataNode))) { - float.appendChild(await this._renderDialog(child)); + if (child.tangible || child instanceof MetadataNode) { + if (!node.hidden?.[child.id]) { + float.appendChild(await this._renderDialog(child)); + } + worker.depends(child, node); } } return { diff --git a/packages/plugin-dialog/test/Dialog.test.ts b/packages/plugin-dialog/test/Dialog.test.ts index aa8763ae0..87d84211c 100644 --- a/packages/plugin-dialog/test/Dialog.test.ts +++ b/packages/plugin-dialog/test/Dialog.test.ts @@ -79,7 +79,9 @@ describe('Dialog', async () => { }); it('should add a vNode in the dialog', async () => { - await editor.plugins.get(Layout).append('aaa', 'float'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'float'); + }); expect(container.innerHTML.replace(/[\s\n]+/g, ' ')).to.equal( [ '', @@ -97,14 +99,18 @@ describe('Dialog', async () => { ); }); it('should add a vNode in the dialog and hide it', async () => { - await editor.plugins.get(Layout).append('aaa', 'float'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'float'); + }); await editor.execCommand('hide', { componentId: 'aaa' }); expect(container.innerHTML.replace(/[\s\n]+/g, ' ')).to.equal( ['', '
', '
'].join(''), ); }); it('should add a vNode in the dialog and show it', async () => { - await editor.plugins.get(Layout).append('aaa', 'float'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'float'); + }); await editor.execCommand('hide', { componentId: 'aaa' }); await editor.execCommand('show', { componentId: 'aaa' }); expect(container.innerHTML.replace(/[\s\n]+/g, ' ')).to.equal( @@ -124,7 +130,9 @@ describe('Dialog', async () => { ); }); it('should add a vNode in dialog because dialog is the default zone', async () => { - await editor.plugins.get(Layout).append('aaa', 'not available zone'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'not available zone'); + }); await editor.execCommand('show', { componentId: 'aaa' }); expect(container.innerHTML.replace(/[\s\n]+/g, ' ')).to.equal( [ @@ -143,10 +151,14 @@ describe('Dialog', async () => { ); }); it('should add 2 vNodes in the dialog and show it', async () => { - await editor.plugins.get(Layout).append('aaa', 'float'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'float'); + }); await editor.execCommand('show', { componentId: 'aaa' }); - await editor.plugins.get(Layout).append('bbb', 'not available zone'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('bbb', 'not available zone'); + }); await editor.execCommand('show', { componentId: 'bbb' }); expect(container.innerHTML.replace(/[\s\n]+/g, ' ')).to.equal( @@ -174,10 +186,14 @@ describe('Dialog', async () => { ); }); it('should close 2 dialogs with the X button', async () => { - await editor.plugins.get(Layout).append('aaa', 'float'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'float'); + }); await editor.execCommand('show', { componentId: 'aaa' }); - await editor.plugins.get(Layout).append('bbb', 'not available zone'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('bbb', 'not available zone'); + }); await editor.execCommand('show', { componentId: 'bbb' }); await click( @@ -217,10 +233,14 @@ describe('Dialog', async () => { expect(section.parent).to.instanceOf(DialogZoneNode); }); it('should close 2 dialogs it with the backdrop', async () => { - await editor.plugins.get(Layout).append('aaa', 'float'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'float'); + }); await editor.execCommand('show', { componentId: 'aaa' }); - await editor.plugins.get(Layout).append('bbb', 'not available zone'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('bbb', 'not available zone'); + }); await editor.execCommand('show', { componentId: 'bbb' }); await click( @@ -259,7 +279,9 @@ describe('Dialog', async () => { expect(section.parent).to.instanceOf(DialogZoneNode); }); it('should close a dialog and re-open a dialog', async () => { - await editor.plugins.get(Layout).append('aaa', 'float'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'float'); + }); await editor.execCommand('show', { componentId: 'aaa' }); await click( @@ -270,7 +292,9 @@ describe('Dialog', async () => { ['', '
', '
'].join(''), ); - await editor.plugins.get(Layout).append('bbb', 'not available zone'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('bbb', 'not available zone'); + }); await editor.execCommand('show', { componentId: 'bbb' }); expect(container.innerHTML.replace(/[\s\n]+/g, ' ')).to.equal( @@ -291,7 +315,9 @@ describe('Dialog', async () => { ); }); it('should not close the dialog if click in content', async () => { - await editor.plugins.get(Layout).append('bbb', 'not available zone'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('bbb', 'not available zone'); + }); await editor.execCommand('show', { componentId: 'bbb' }); await click(Array.from(container.querySelectorAll('jw-dialog jw-content')).pop()); @@ -314,7 +340,9 @@ describe('Dialog', async () => { ); }); it('should hide a vNode in dialog (without remove the vNode)', async () => { - await editor.plugins.get(Layout).append('bbb', 'not available zone'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('bbb', 'not available zone'); + }); await editor.execCommand('hide', { componentId: 'bbb' }); diff --git a/packages/plugin-divider/src/DividerXmlDomParser.ts b/packages/plugin-divider/src/DividerXmlDomParser.ts index 44c7e640d..f32e8b8ba 100644 --- a/packages/plugin-divider/src/DividerXmlDomParser.ts +++ b/packages/plugin-divider/src/DividerXmlDomParser.ts @@ -13,7 +13,10 @@ export class DividerXmlDomParser extends AbstractParser { async parse(item: Element): Promise { const divider = new DividerNode(); - divider.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + divider.modifiers.append(attributes); + } const nodes = await this.engine.parse(...item.childNodes); divider.append(...nodes); diff --git a/packages/plugin-dom-editable/src/DomEditable.ts b/packages/plugin-dom-editable/src/DomEditable.ts index bcacfa5a6..b8d5537fe 100644 --- a/packages/plugin-dom-editable/src/DomEditable.ts +++ b/packages/plugin-dom-editable/src/DomEditable.ts @@ -37,6 +37,7 @@ export class DomEditable extends JWPl async stop(): Promise { this.eventNormalizer.destroy(); window.removeEventListener('keydown', this._processKeydown); + return super.stop(); } /** diff --git a/packages/plugin-dom-editable/test/DomEditable.test.ts b/packages/plugin-dom-editable/test/DomEditable.test.ts index 6d0298b47..c57956219 100644 --- a/packages/plugin-dom-editable/test/DomEditable.test.ts +++ b/packages/plugin-dom-editable/test/DomEditable.test.ts @@ -180,8 +180,16 @@ describe('DomEditable', () => { await editor.start(); commandNames = []; const execCommand = editor.execCommand; - editor.execCommand = async (commandName: string, params: object): Promise => { - commandNames.push(commandName); + editor.execCommand = async ( + commandName: string | (() => Promise | void), + params?: object, + ): Promise => { + if (typeof commandName === 'function') { + commandNames.push('@custom'); + await commandName(); + } else { + commandNames.push(commandName); + } return execCommand.call(editor, commandName, params); }; }); @@ -832,14 +840,14 @@ describe('DomEditable', () => { await selectAllWithKeyA(container1); await selectAllWithKeyA(container2); - await editor.stop(); - await editor2.stop(); - const params = { context: editor.contextManager.defaultContext, }; expect(execSpy.args).to.eql([['command-b', params]]); expect(execSpy2.args).to.eql([['selectAll', {}]]); + + await editor.stop(); + await editor2.stop(); }); it('deleteContentBackward (SwiftKey) with special keymap', async () => { section.innerHTML = '
abcd
'; @@ -875,8 +883,16 @@ describe('DomEditable', () => { await editor.start(); commandNames = []; const execCommand = editor.execCommand; - editor.execCommand = async (commandName: string, params: object): Promise => { - commandNames.push(commandName); + editor.execCommand = async ( + commandName: string | (() => Promise | void), + params?: object, + ): Promise => { + if (typeof commandName === 'function') { + commandNames.push('@custom'); + await commandName(); + } else { + commandNames.push(commandName); + } return execCommand.call(editor, commandName, params); }; diff --git a/packages/plugin-dom-follow-range/test/FollowRange.test.ts b/packages/plugin-dom-follow-range/test/FollowRange.test.ts index 7b2a3b5fd..0bd68e122 100644 --- a/packages/plugin-dom-follow-range/test/FollowRange.test.ts +++ b/packages/plugin-dom-follow-range/test/FollowRange.test.ts @@ -82,8 +82,11 @@ describe('FollowRange', async () => { location: [section, 'replace'], }); await editor.start(); - await editor.plugins.get(Layout).append('aaa', 'range'); - await editor.plugins.get(Layout).append('bbb', 'range'); + + await editor.execCommand(async () => { + await editor.plugins.get(Layout).append('aaa', 'range'); + await editor.plugins.get(Layout).append('bbb', 'range'); + }); expect(container.innerHTML.replace(/[\s\n]+/g, ' ')).to.equal( [ '', diff --git a/packages/plugin-dom-layout/src/ActionableDomObjectRenderer.ts b/packages/plugin-dom-layout/src/ActionableDomObjectRenderer.ts index 1d8d71803..7d09d28d2 100644 --- a/packages/plugin-dom-layout/src/ActionableDomObjectRenderer.ts +++ b/packages/plugin-dom-layout/src/ActionableDomObjectRenderer.ts @@ -15,8 +15,17 @@ export class ActionableDomObjectRenderer extends NodeRenderer { engine: DomObjectRenderingEngine; predicate: Predicate = ActionableNode; + actionableNodes = new Map(); + + constructor(engine: DomObjectRenderingEngine) { + super(engine); + this.engine.editor.dispatcher.registerCommandHook( + '@commit', + this._updateActionables.bind(this), + ); + } + async render(button: ActionableNode): Promise { - let updateButton: () => void; let handler: (ev: MouseEvent) => void; const objectButton: DomObjectActionable = { tag: 'BUTTON', @@ -34,13 +43,11 @@ export class ActionableDomObjectRenderer extends NodeRenderer { objectButton.handler(); }; el.addEventListener('click', handler); - updateButton = this._updateButton.bind(this, button, el); - updateButton(); - this.engine.editor.dispatcher.registerCommandHook('*', updateButton); + this.actionableNodes.set(button, el); }, detach: (el: HTMLButtonElement): void => { el.removeEventListener('click', handler); - this.engine.editor.dispatcher.removeCommandHook('*', updateButton); + this.actionableNodes.delete(button); }, }; const attributes = button.modifiers.find(Attributes); @@ -55,33 +62,38 @@ export class ActionableDomObjectRenderer extends NodeRenderer { return objectButton; } + /** * Update button rendering after the command if the value of selected or * enabled change. - * - * @param button - * @param element */ - private _updateButton(button: ActionableNode, element: HTMLButtonElement): void { - const editor = this.engine.editor; - const select = !!button.selected(editor); - const enable = !!button.enabled(editor); + protected _updateActionables(): void { + const commandNames = this.engine.editor.memoryInfo.commandNames; + if (commandNames.length === 1 && commandNames.includes('insertText')) { + // By default the actionable buttons are not update for a text insertion. + return; + } + for (const [actionable, element] of this.actionableNodes) { + const editor = this.engine.editor; + const select = !!actionable.selected(editor); + const enable = !!actionable.enabled(editor); - const attrSelected = element.getAttribute('aria-pressed'); - if (select.toString() !== attrSelected) { - element.setAttribute('aria-pressed', select.toString()); - if (select) { - element.classList.add('pressed'); - } else { - element.classList.remove('pressed'); + const attrSelected = element.getAttribute('aria-pressed'); + if (select.toString() !== attrSelected) { + element.setAttribute('aria-pressed', select.toString()); + if (select) { + element.classList.add('pressed'); + } else { + element.classList.remove('pressed'); + } } - } - const domEnable = !element.getAttribute('disabled'); - if (enable !== domEnable) { - if (enable) { - element.removeAttribute('disabled'); - } else { - element.setAttribute('disabled', 'true'); + const domEnable = !element.getAttribute('disabled'); + if (enable !== domEnable) { + if (enable) { + element.removeAttribute('disabled'); + } else { + element.setAttribute('disabled', 'true'); + } } } } diff --git a/packages/plugin-dom-layout/src/ActionableGroupSelectItemDomObjectRenderer.ts b/packages/plugin-dom-layout/src/ActionableGroupSelectItemDomObjectRenderer.ts index 8fb967469..5bdacf145 100644 --- a/packages/plugin-dom-layout/src/ActionableGroupSelectItemDomObjectRenderer.ts +++ b/packages/plugin-dom-layout/src/ActionableGroupSelectItemDomObjectRenderer.ts @@ -11,16 +11,26 @@ import { VNode } from '../../core/src/VNodes/VNode'; import { LabelNode } from '../../plugin-layout/src/LabelNode'; import { ZoneNode } from '../../plugin-layout/src/ZoneNode'; import { DomObjectActionable } from './ActionableDomObjectRenderer'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class ActionableGroupSelectItemDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; engine: DomObjectRenderingEngine; predicate = (node: VNode): boolean => node.ancestors(ActionableGroupNode).length >= 2; - async render(item: VNode): Promise { + actionableNodes = new Map(); + + constructor(engine: DomObjectRenderingEngine) { + super(engine); + this.engine.editor.dispatcher.registerCommandHook( + '@commit', + this._updateActionables.bind(this), + ); + } + + async render(item: VNode, worker: RenderingEngineWorker): Promise { let domObject: DomObject; if (item instanceof ActionableNode) { - let updateOption: () => void; let handler: (ev: Event) => void; const domObjectActionable: DomObjectActionable = { tag: 'OPTION', @@ -43,14 +53,12 @@ export class ActionableGroupSelectItemDomObjectRenderer extends NodeRenderer { const select = el.closest('select') || el.parentElement; select.removeEventListener('change', handler); - this.engine.editor.dispatcher.removeCommandHook('*', updateOption); + this.actionableNodes.delete(item); }, }; domObject = domObjectActionable; @@ -88,7 +96,7 @@ export class ActionableGroupSelectItemDomObjectRenderer extends NodeRenderer extends JWPlugin { @@ -53,7 +51,7 @@ export class DomLayout extends JWPl domLocations: this._loadComponentLocations, }; commandHooks = { - '*': this.redraw, + '@commit': this._redraw, }; constructor(editor: JWEditor, configuration: T) { @@ -76,9 +74,6 @@ export class DomLayout extends JWPl this._loadComponentLocations(this.configuration.locations || []); domLayoutEngine.location = this.configuration.location; await domLayoutEngine.start(); - if (this.configuration.afterRender) { - await this.configuration.afterRender(); - } window.addEventListener('keydown', this.processKeydown, true); } async stop(): Promise { @@ -86,6 +81,7 @@ export class DomLayout extends JWPl const layout = this.dependencies.get(Layout); const domLayoutEngine = layout.engines.dom; await domLayoutEngine.stop(); + return super.stop(); } //-------------------------------------------------------------------------- @@ -122,25 +118,6 @@ export class DomLayout extends JWPl } } - async redraw(): Promise { - // TODO: adapt when add memory - // * redraw node with change - // * redraw children if add or remove children (except for selection) - const layout = this.dependencies.get(Layout); - const domLayoutEngine = layout.engines.dom as DomLayoutEngine; - const editables = domLayoutEngine.components.get('editable'); - if (editables?.length) { - const nodes = [...editables]; - for (const node of nodes) { - if (node instanceof ContainerNode) { - nodes.push(...node.childVNodes); - } - } - await domLayoutEngine.redraw(...nodes); - await this.configuration.afterRender?.(); - } - } - /** * Return true if the target node is inside Jabberwock's main editable Zone. * @@ -202,4 +179,9 @@ export class DomLayout extends JWPl } return isAnchorDescendantOfTarget ? anchorNode : target; } + private async _redraw(params: CommitParams): Promise { + const layout = this.dependencies.get(Layout); + const domLayoutEngine = layout.engines.dom as DomLayoutEngine; + await domLayoutEngine.redraw(params.changesLocations); + } } diff --git a/packages/plugin-dom-layout/src/DomLayoutEngine.ts b/packages/plugin-dom-layout/src/DomLayoutEngine.ts index 41165ef29..cad7b1517 100644 --- a/packages/plugin-dom-layout/src/DomLayoutEngine.ts +++ b/packages/plugin-dom-layout/src/DomLayoutEngine.ts @@ -5,10 +5,8 @@ import { DomZonePosition, ComponentDefinition, } from '../../plugin-layout/src/LayoutEngine'; -import { Renderer } from '../../plugin-renderer/src/Renderer'; -import { ZoneNode, ZoneIdentifier } from '../../plugin-layout/src/ZoneNode'; +import { ZoneNode } from '../../plugin-layout/src/ZoneNode'; import { Direction, VSelectionDescription } from '../../core/src/VSelection'; -import { ContainerNode } from '../../core/src/VNodes/ContainerNode'; import { DomSelectionDescription } from '../../plugin-dom-editable/src/EventNormalizer'; import JWEditor from '../../core/src/JWEditor'; import { DomReconciliationEngine } from './DomReconciliationEngine'; @@ -19,6 +17,11 @@ import { } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; import { VElement } from '../../core/src/VNodes/VElement'; import { flat } from '../../utils/src/utils'; +import { Modifier } from '../../core/src/Modifier'; +import { RenderingEngineCache } from '../../plugin-renderer/src/RenderingEngineCache'; +import { ChangesLocations } from '../../core/src/Memory/Memory'; +import { AbstractNode } from '../../core/src/VNodes/AbstractNode'; +import { Renderer } from '../../plugin-renderer/src/Renderer'; export type DomPoint = [Node, number]; export type DomLayoutLocation = [Node, DomZonePosition]; @@ -35,6 +38,8 @@ export class DomLayoutEngine extends LayoutEngine { location: [Node, DomZonePosition]; locations: Record = {}; + private _rendererCache: RenderingEngineCache; + defaultRootComponent: ComponentDefinition = { id: 'editor', async render(): Promise { @@ -66,7 +71,6 @@ export class DomLayoutEngine extends LayoutEngine { } await super.start(); - await this.redraw(); } async stop(): Promise { for (const componentId in this.componentDefinitions) { @@ -89,8 +93,10 @@ export class DomLayoutEngine extends LayoutEngine { } } this.renderingMap = {}; + this._markedForRedraw = new Set(); this.location = null; this.locations = {}; + this._rendererCache = null; this._domReconciliationEngine.clear(); return super.stop(); } @@ -115,150 +121,26 @@ export class DomLayoutEngine extends LayoutEngine { getDomNodes(node: VNode): Node[] { return this._domReconciliationEngine.toDom(node); } - /** - * Redraw the layout component after insertion. - * If the target zone is the root, prepare its location before redrawing. - * - * @override - */ - async prepend(componentId: ComponentId, zoneId: ZoneIdentifier, props?: {}): Promise { - const nodes = await super.prepend(componentId, zoneId, props); - // Filter out children of nodes that we are already going to redraw. - const nodeToRedraw = nodes.filter(node => !node.ancestor(n => nodes.includes(n))); - for (const node of nodeToRedraw) { - nodeToRedraw.push(...node.childVNodes); - } - // only used if the zone want to return a Node but hide the component (eg: a panel) - // TODO: adapt when add memory - await this.redraw(...nodeToRedraw); - return nodes; - } - /** - * Redraw the layout component after insertion. - * If the target zone is the root, prepare its location before redrawing. - * - * @override - */ - async append(componentId: ComponentId, zoneId: ZoneIdentifier, props?: {}): Promise { - const nodes = await super.append(componentId, zoneId, props); - // Filter out children of nodes that we are already going to redraw. - const nodeToRedraw = nodes.filter(node => !node.ancestor(n => nodes.includes(n))); - for (const node of nodeToRedraw) { - nodeToRedraw.push(...node.childVNodes); - } - // only used if the zone want to return a Node but hide the component (eg: a panel) - // TODO: adapt when add memory - await this.redraw(...nodeToRedraw); - return nodes; - } - /** - * Redraw the layout component after removal. - * - * @override - */ - async remove(componentId: ComponentId): Promise { - const zones = await super.remove(componentId); - // TODO: adapt when add memory - await this.redraw(...zones); - return zones; - } - /** - * Redraw the layout component after showing the component. - * - * @override - */ - async show(componentId: ComponentId): Promise { - const nodes = await super.show(componentId); - const nodeToRedraw = [...nodes]; - for (const node of nodeToRedraw) { - nodeToRedraw.push(...node.childVNodes); - } - for (const node of nodes) { - nodeToRedraw.push(node.ancestor(ZoneNode)); - } - // TODO: adapt when add memory - await this.redraw(...nodeToRedraw); - return nodes; - } - /** - * Redraw the layout component after hidding the component. - * - * @override - */ - async hide(componentId: ComponentId): Promise { - const nodes = await super.hide(componentId); - const nodeToRedraw = [...nodes]; - for (const node of nodes) { - nodeToRedraw.push(node.ancestor(ZoneNode)); - } - // TODO: adapt when add memory - await this.redraw(...nodeToRedraw); - return nodes; - } - async redraw(...nodes: VNode[]): Promise { - if ( - !this.editor.enableRender || - (this.editor.preventRenders && this.editor.preventRenders.size) - ) { - return; - } + async redraw(params: ChangesLocations): Promise { if (this._currentlyRedrawing) { throw new Error('Double redraw detected'); } this._currentlyRedrawing = true; - if (nodes.length) { - for (let node of nodes) { - while ( - (this._domReconciliationEngine.getRenderedWith(node).length !== 1 || - !this._domReconciliationEngine.toDom(node).length) && - node.parent - ) { - // If the node are redererd with some other nodes then redraw parent. - // If not in layout then redraw the parent. - node = node.parent; - if (!nodes.includes(node)) { - nodes.push(node); - } - } - } - for (const node of [...nodes]) { - // Add direct siblings nodes for batched nodes with format. - const previous = node.previous(); - if (previous && !nodes.includes(previous)) { - nodes.push(previous); - } - const next = node.next(); - if (next && !nodes.includes(next)) { - nodes.push(next); - } - } - } else { - // Redraw all. - for (const componentId in this.locations) { - nodes.push(...this.components.get(componentId)); - } - for (const node of nodes) { - if (node instanceof ContainerNode) { - nodes.push(...node.childVNodes); - } - } - } - - nodes = nodes.filter(node => { - const ancestor = node.ancestors(ZoneNode).pop(); - return ancestor?.managedZones.includes('root'); - }); + const updatedNodes = [...this._getInvalidNodes(params)]; - // Render nodes. - const renderer = this.editor.plugins.get(Renderer); - const domObjects = await renderer.render('dom/object', nodes); - const engine = renderer.engines['dom/object'] as DomObjectRenderingEngine; + const layout = this.editor.plugins.get(Renderer); + const engine = layout.engines['dom/object'] as DomObjectRenderingEngine; + const cache = (this._rendererCache = await engine.render( + updatedNodes, + this._rendererCache, + )); this._domReconciliationEngine.update( - domObjects || [], - engine.locations, - engine.from, + updatedNodes, + cache.renderings, + cache.locations, + cache.renderingDependent, this._markedForRedraw, ); this._markedForRedraw = new Set(); @@ -329,6 +211,302 @@ export class DomLayoutEngine extends LayoutEngine { // Private //-------------------------------------------------------------------------- + /** + * Get the invalidated nodes in the rendering. + * Clear the renderer in cache for this node or modifier. The cache + * renderer is added only for performance at redrawing time. The + * invalidation are automatically made from memory changes. + */ + private _getInvalidNodes(diff: ChangesLocations): Set { + const cache = this._rendererCache; + const remove = new Set(); + const update = new Set(); + const updatedModifiers = new Set(); + const updatedSiblings = new Set(); + + const add = new Set(); + + // Add new nodes for redrawing it. + for (const object of diff.add) { + if (object instanceof AbstractNode) { + add.add(object as VNode); + } else if (object instanceof Modifier) { + updatedModifiers.add(object); + } + } + + for (const node of add) { + if (!node.parent) { + add.delete(node); + remove.add(node); + for (const child of node.descendants()) { + add.delete(node); + remove.add(child); + } + } + } + + if (cache) { + // Select the removed VNode and Modifiers. + const allRemove = new Set(diff.remove); + for (const object of diff.remove) { + if (object instanceof AbstractNode) { + remove.add(object as VNode); + } else { + if (object instanceof Modifier) { + updatedModifiers.add(object); + } + for (const [parent] of this.editor.memory.getParents(object)) { + if (parent instanceof AbstractNode) { + update.add(parent as VNode); + } else if (parent instanceof Modifier) { + updatedModifiers.add(parent); + } + } + } + } + const filterd = this._filterInRoot([...remove]); + for (const node of filterd.remove) { + update.delete(node); + remove.add(node); + for (const child of node.descendants()) { + remove.add(child); + } + } + for (const node of filterd.keep) { + update.add(node); // TODO: memory change to have real add and not add + move. + } + + const needSiblings = new Set(); + + // Filter to keep only update not added or removed nodes. + const paramsUpdate: [object, string[] | number[] | void][] = []; + diff.update.filter(up => { + const object = up[0]; + if ( + up[1] && + object instanceof AbstractNode && + (up[1] as string[]).includes('parent') && + !object.parent + ) { + remove.add(object as VNode); + for (const child of object.descendants()) { + remove.add(child); + } + } else if (!remove.has(object as VNode)) { + paramsUpdate.push(up); + } + }); + + // Select the updated VNode and Modifiers and the VNode siblings. + // From the parent, select the removed VNode siblings. + for (const [object, changes] of paramsUpdate) { + if ( + allRemove.has(object) || + update.has(object as VNode) || + updatedModifiers.has(object as Modifier) + ) { + continue; + } + if (object instanceof AbstractNode) { + update.add(object as VNode); + needSiblings.add(object); + } else { + if (object instanceof Modifier) { + updatedModifiers.add(object); + } + for (const [parent, parentProp] of this.editor.memory.getParents(object)) { + if (parent instanceof AbstractNode) { + update.add(parent as VNode); + if ( + changes && + parentProp[0][0] === 'childVNodes' && + typeof changes[0] === 'number' + ) { + // If change a children (add or remove) redraw the node and + // siblings. + const childVNodes = parent.childVNodes; + for (let i = 0; i < changes.length; i++) { + const index = changes[i] as number; + const child = childVNodes[index]; + if (child) { + if (!add.has(child)) { + update.add(child); + } + if (changes[i - 1] !== index - 1) { + const previous = child.previousSibling(); + if ( + previous && + !add.has(previous) && + !update.has(previous) + ) { + updatedSiblings.add(previous); + } + } + if (changes[i + 1] !== index + 1) { + const next = child.nextSibling(); + if (next && !add.has(next) && !update.has(next)) { + if (next) updatedSiblings.add(next); + } + } + } else { + const children = parent.children(); + if (children.length) { + const last = children[children.length - 1]; + if (last && !add.has(last) && !update.has(last)) { + updatedSiblings.add(last); + } + } + } + } + } else { + needSiblings.add(parent); + } + } else if (parent instanceof Modifier) { + updatedModifiers.add(parent); + } + } + } + } + + // If any change invalidate the siblings. + for (const node of needSiblings) { + const next = node.nextSibling(); + if (next) updatedSiblings.add(next); + const previous = node.previousSibling(); + if (previous) updatedSiblings.add(previous); + } + + // Invalidate compatible renderer cache. + for (const node of update) { + cache.cachedCompatibleRenderer.delete(node); + } + + // Invalidate compatible renderer cache and modifier compare cache. + for (const modifier of updatedModifiers) { + cache.cachedCompatibleModifierRenderer.delete(modifier); + const id = cache.cachedModifierId.get(modifier); + if (id) { + const keys = cache.cachedIsSameAsModifierIds[id]; + if (keys) { + for (const key in keys) { + delete cache.cachedIsSameAsModifier[key]; + } + delete cache.cachedIsSameAsModifierIds[id]; + } + } + } + + // Add the siblings to invalidate the sibling groups. + for (const sibling of updatedSiblings) { + update.add(sibling); + } + + // Get all linked and dependent VNodes and Modifiers to invalidate cache. + const treated = new Set(); + const nodesOrModifiers = [...update, ...updatedModifiers]; + const treatedItem = new Set(nodesOrModifiers); + for (const nodeOrModifier of nodesOrModifiers) { + const linkedRenderings = cache.nodeDependent.get(nodeOrModifier); + if (linkedRenderings) { + for (const link of linkedRenderings) { + if (!treated.has(link)) { + treated.add(link); + const from = cache.renderingDependent.get(link); + if (from) { + for (const n of from) { + if (!treatedItem.has(n)) { + // Add to invalid domObject origin nodes or modifiers. + nodesOrModifiers.push(n); + treatedItem.add(n); + } + } + } + } + } + } + const linkedNodes = cache.linkedNodes.get(nodeOrModifier); + if (linkedNodes) { + for (const node of linkedNodes) { + if (!treatedItem.has(node)) { + // Add to invalid linked nodes of linkes nodes. + nodesOrModifiers.push(node); + treatedItem.add(node); + } + } + } + if (nodeOrModifier instanceof AbstractNode) { + update.add(nodeOrModifier); + } else { + updatedModifiers.add(nodeOrModifier); + } + } + + // Invalidate VNode cache origin, location and linked. + for (const node of update) { + cache.renderingPromises.delete(node); + const item = cache.renderings.get(node); + if (item) { + cache.renderingDependent.delete(item); + cache.locations.delete(item); + } + cache.renderings.delete(node); + cache.nodeDependent.delete(node); + cache.linkedNodes.delete(node); + } + + // Remove all removed children and modifiers. + for (const node of remove) { + update.delete(node); + if (node.modifiers) { + // If the node is created after this memory slice (undo), the + // node has no values, no id, no modifiers... + node.modifiers.map(modifier => updatedModifiers.add(modifier)); + } + } + + // Invalidate Modifiers cache linked. + for (const modifier of updatedModifiers) { + cache.nodeDependent.delete(modifier); + } + } + for (const node of add) { + update.add(node); + } + + // Render nodes. + return update; + } + private _filterInRoot(nodes: VNode[]): { keep: Set; remove: Set } { + const inRoot = new Set(); + const notRoot = new Set(); + const nodesInRoot = new Set(); + const nodesInNotRoot = new Set(); + for (const node of nodes) { + const parents: VNode[] = []; + let ancestor = node; + while (ancestor && !notRoot.has(ancestor)) { + if (ancestor === this.root || inRoot.has(ancestor)) { + // The VNode is in the domLayout. + nodesInRoot.add(node); + for (const parent of parents) { + inRoot.add(parent); + } + break; + } + parents.push(ancestor); + ancestor = ancestor.parent; + if (!ancestor) { + // The VNode is not in the domLayout. + nodesInNotRoot.add(node); + for (const parent of parents) { + notRoot.add(parent); + } + } + } + } + return { keep: nodesInRoot, remove: nodesInNotRoot }; + } /** * Render the given VSelection as a DOM selection in the given target. * diff --git a/packages/plugin-dom-layout/src/DomReconciliationEngine.ts b/packages/plugin-dom-layout/src/DomReconciliationEngine.ts index 2cd8f7278..c230a2f1d 100644 --- a/packages/plugin-dom-layout/src/DomReconciliationEngine.ts +++ b/packages/plugin-dom-layout/src/DomReconciliationEngine.ts @@ -56,9 +56,10 @@ function setStyle(element: HTMLElement, name: string, value: string): void { export class DomReconciliationEngine { private _objects: Record = {}; + private readonly _objectIds = new Map(); private readonly _fromItem = new Map(); private readonly _fromDom = new Map(); - private readonly _renderedNodes = new Map(); + private readonly _renderedNodes = new Map>(); private readonly _renderedIds = new Set(); private readonly _locations = new Map(); @@ -67,72 +68,86 @@ export class DomReconciliationEngine { // The diff is filled in update when we compare the new domObject with the // old one, and the diff are consumed when we redraw the node. private readonly _diff: Record = {}; - private _rendererTreated = new Set(); + private readonly _rendererTreated = new Set(); private _domUpdated = new Set(); update( - rendered: DomObject[], + updatedNodes: VNode[], + renderings: Map, locations: Map, - from: Map>, + from: Map>, domNodesToRedraw = new Set(), ): void { + const renderedSet = new Set(); + for (const node of updatedNodes) { + renderedSet.add(renderings.get(node)); + } + const rendered = [...renderedSet]; + // Found the potential old values (they could become children of the current node). // In old values the renderer are may be merge some object, we want to found the // children object in old value to campare it with the newest. - const unfilterdOldObjectMap = new Map(); + const mapOldIds = new Map>(); + const domObjects: GenericDomObject[] = []; for (const domObject of rendered) { - let oldObjects = unfilterdOldObjectMap.get(domObject); + if (this._rendererTreated.has(domObject)) { + continue; + } + domObjects.push(domObject); + let oldObjects = mapOldIds.get(domObject); if (!oldObjects) { this._addLocations(domObject, locations, from); - oldObjects = []; - unfilterdOldObjectMap.set(domObject, oldObjects); + oldObjects = new Set(); + mapOldIds.set(domObject, oldObjects); } const nodes = this._items.get(domObject); for (const linkedNode of nodes) { - const id = this._renderedNodes.get(linkedNode); - if (id && !oldObjects.includes(id)) { - oldObjects.push(id); + const ids = this._renderedNodes.get(linkedNode); + if (ids) { + for (const id of ids) { + if (!oldObjects.has(id)) { + const object = this._objects[id].object; + this._rendererTreated.delete(object); + this._objectIds.delete(object); + this._renderedIds.delete(id); + oldObjects.add(id); + } + } } } - } - - // prepare mapping for diff - const nodeToDomObject = new Map(); - for (const domObject of rendered) { - for (const node of this._items.get(domObject)) { - nodeToDomObject.set(node, domObject); - // re-instert just after if available but the id can change - this._renderedIds.delete(this._renderedNodes.get(node)); - } + this._rendererTreated.delete(domObject); + this._objectIds.delete(domObject); } // Make diff. - this._rendererTreated.clear(); - for (const domObject of rendered) { - if (!this._rendererTreated.has(domObject)) { + for (const domObject of domObjects) { + if (!this._objectIds.has(domObject)) { const items = this._items.get(domObject); const node = items.find(node => node instanceof AbstractNode) as VNode; const oldRefId = this._fromItem.get(node); - const parentId = this._objects[oldRefId]?.parent; - const oldIds = unfilterdOldObjectMap.get(domObject); - const id = this._diffObject(nodeToDomObject, domObject, items, from, oldIds); + const oldIds = mapOldIds.get(domObject); + const id = this._diffObject(renderings, domObject, items, mapOldIds); this._renderedIds.add(id); - + const parentObject = this._objects[this._objects[id].parent]?.object; if ( - (oldIds.length > 1 || (id !== oldRefId && !this._diff[parentId])) && - !nodeToDomObject.get(node.parent) + oldRefId !== id || + !parentObject || + !this._rendererTreated.has(parentObject) || + oldIds.size > 1 ) { + // If the rendering change, we must check if we redraw the parent. const ancestorWithRendering = node.ancestor( ancestor => !!this._fromItem.get(ancestor), ); - const ancestorObjectId = this._fromItem.get(ancestorWithRendering); - if (ancestorObjectId) { - // this._objects[id].parent = ancestorObjectId; - const parentObject = this._objects[ancestorObjectId]; - const nodes = this._items.get(parentObject.object); - this._diffObject(nodeToDomObject, parentObject.object, nodes, from, [ - ancestorObjectId, - ]); + if (!updatedNodes.includes(ancestorWithRendering)) { + const ancestorObjectId = this._fromItem.get(ancestorWithRendering); + if (ancestorObjectId && !this._diff[ancestorObjectId]) { + const parentObject = this._objects[ancestorObjectId]; + const nodes = this._items.get(parentObject.object); + mapOldIds.set(parentObject.object, new Set([ancestorObjectId])); + this._rendererTreated.delete(parentObject.object); + this._diffObject(renderings, parentObject.object, nodes, mapOldIds); + } } } } @@ -202,7 +217,8 @@ export class DomReconciliationEngine { } } for (const node of this._items.get(old.object) || []) { - if (this._renderedNodes.get(node) === id) { + const ids = this._renderedNodes.get(node); + if (ids && ids.has(id)) { this._renderedNodes.delete(node); } } @@ -236,7 +252,7 @@ export class DomReconciliationEngine { if (id) { if (this._diff[id]) { this._diff[id].askCompleteRedrawing = true; - } else { + } else if (this._objects[id]) { const domObject = this._objects[id].object; this._diff[id] = { id: id, @@ -339,6 +355,7 @@ export class DomReconciliationEngine { object.object.attach(...object.dom); } } + this._domUpdated.clear(); } /** @@ -496,7 +513,7 @@ export class DomReconciliationEngine { const locations = this._locations.get(object.object); if (!locations[offset]) { return [locations[locations.length - 1], RelativePosition.AFTER]; - } else if (forcePrepend && locations[offset].is(ContainerNode)) { + } else if (forcePrepend && locations[offset] instanceof ContainerNode) { return [locations[offset], RelativePosition.INSIDE]; } else if (forceAfter) { return [locations[offset], RelativePosition.AFTER]; @@ -523,10 +540,14 @@ export class DomReconciliationEngine { } this._objects = {}; this._fromItem.clear(); - this._renderedNodes.clear(); this._fromDom.clear(); + this._renderedNodes.clear(); this._renderedIds.clear(); + this._objectIds.clear(); + this._locations.clear(); + this._items.clear(); this._rendererTreated.clear(); + this._domUpdated.clear(); } /** @@ -552,6 +573,9 @@ export class DomReconciliationEngine { if (!reference) { reference = node.parent; position = RelativePosition.INSIDE; + if (!reference) { + return; + } } let object: DomObjectMapping; @@ -562,31 +586,37 @@ export class DomReconciliationEngine { const alreadyCheck = new Set(); while (!domNodes && reference) { alreadyCheck.add(reference); - let id = this._renderedNodes.get(reference); - if (id) { - object = this._objects[id]; - locations = this._locations.get(object.object); - if (!locations.includes(reference)) { - let hasLocate: number; - const ids = [id]; - while (ids.length && (!hasLocate || position === RelativePosition.AFTER)) { - const id = ids.pop(); - const child = this._objects[id]; - if (this._locations.get(child.object).includes(reference)) { - hasLocate = id; - } - if (child.children) { - ids.push(...[...child.children].reverse()); - } - } - id = hasLocate; + const ids = this._renderedNodes.get(reference); + if (ids) { + for (let id of ids) { object = this._objects[id]; locations = this._locations.get(object.object); - } - if (object.dom.length) { - domNodes = object.dom; - } else { - domNodes = this._getchildrenDomNodes(id); + if (!locations.includes(reference)) { + let hasLocate: number; + const ids = [id]; + while (ids.length && (!hasLocate || position === RelativePosition.AFTER)) { + const id = ids.pop(); + const child = this._objects[id]; + if (this._locations.get(child.object).includes(reference)) { + hasLocate = id; + } + if (child.children) { + ids.push(...[...child.children].reverse()); + } + } + id = hasLocate; + object = this._objects[id]; + locations = this._locations.get(object.object); + } + if (object.dom.length) { + if (!domNodes) domNodes = []; + + domNodes.push(...object.dom); + } else { + if (!domNodes) domNodes = []; + + domNodes.push(...this._getchildrenDomNodes(id)); + } } } @@ -643,21 +673,22 @@ export class DomReconciliationEngine { nodeToDomObject: Map, domObject: GenericDomObject, fromNodes: Array, - from: Map>, - oldIds?: DomObjectID[], + mapOldIds?: Map>, childrenMapping?: Map, ): DomObjectID { - this._rendererTreated.add(domObject); + let oldIds = mapOldIds.get(domObject); const items = this._items.get(domObject); if (!oldIds) { - oldIds = []; + oldIds = new Set(); if (items) { for (const item of items) { - const id = this._renderedNodes.get(item); - if (id && !this._diff[id]) { - if (!oldIds.includes(id)) { - oldIds.push(id); + const ids = this._renderedNodes.get(item); + if (ids) { + for (const id of ids) { + if (!this._diff[id]) { + oldIds.add(id); + } } } } @@ -665,28 +696,38 @@ export class DomReconciliationEngine { } let hasChanged = false; - if (oldIds.length) { - oldIds = oldIds.filter(id => this._objects[id] && !this._rendererTreated.has(id)); + if (oldIds.size) { + for (const id of [...oldIds]) { + const old = this._objects[id]; + if (!old || this._rendererTreated.has(old.object)) { + oldIds.delete(id); + } + } if (!childrenMapping) { childrenMapping = this._diffObjectAssociateChildrenMap(domObject, oldIds); } - hasChanged = oldIds.length !== 1; + hasChanged = oldIds.size !== 1; } let id = childrenMapping?.get(domObject); - if (id && !oldIds.includes(id)) { - oldIds.push(id); + + if (id) { + oldIds.add(id); } - if (id && !this._rendererTreated.has(id)) { + let old = this._objects[id]; + + if (old && !this._rendererTreated.has(old.object)) { childrenMapping.delete(domObject); - this._rendererTreated.add(id); + this._rendererTreated.add(old.object); } else { + old = null; hasChanged = true; diffObjectId++; id = diffObjectId; } - const old = this._objects[id]; + this._rendererTreated.add(domObject); + this._objectIds.set(domObject, id); const removedChildren: DomObjectID[] = []; const diffAttributes: Record = {}; @@ -800,8 +841,14 @@ export class DomReconciliationEngine { for (const child of newChildren) { let childId: DomObjectID; if (child instanceof AbstractNode) { - const oldChildId = this._renderedNodes.get(child) || this._fromItem.get(child); const domObject = nodeToDomObject.get(child); + let oldChildId = this._objectIds.get(domObject) || this._fromItem.get(child); + if (!oldChildId) { + const oldChildIds = this._renderedNodes.get(child); + if (oldChildIds?.size) { + oldChildId = [...oldChildIds][0]; + } + } const nodes = this._items.get(domObject); if (this._rendererTreated.has(domObject)) { childId = oldChildId; @@ -814,22 +861,17 @@ export class DomReconciliationEngine { ); } } else { - childId = this._diffObject( - nodeToDomObject, - domObject, - nodes, - from, - oldChildId && !this._renderedIds.has(oldChildId) && [oldChildId], - ); + childId = this._diffObject(nodeToDomObject, domObject, nodes, mapOldIds); this._renderedIds.add(childId); } + } else if (this._rendererTreated.has(child)) { + childId = this._objectIds.get(child); } else { childId = this._diffObject( nodeToDomObject, child, nodes, - from, - null, + mapOldIds, childrenMapping, ); } @@ -942,7 +984,7 @@ export class DomReconciliationEngine { // remove old referencies const oldIdsToRelease: DomObjectID[] = []; - if (items && oldIds.length) { + if (items && oldIds.size) { oldIdsToRelease.push(...oldIds); } if (old) { @@ -954,7 +996,8 @@ export class DomReconciliationEngine { for (const id of oldIdsToRelease) { const old = this._objects[id]; for (const item of this._items.get(old.object)) { - if (this._renderedNodes.get(item) === id) { + const ids = this._renderedNodes.get(item); + if (ids && ids.has(id)) { this._renderedNodes.delete(item); } } @@ -965,11 +1008,13 @@ export class DomReconciliationEngine { this._fromItem.set(node, id); } if (items) { - for (const item of items) { - this._renderedNodes.set(item, id); - } - for (const item of this._items.get(domObject)) { - this._renderedNodes.set(item, id); + for (const item of [...items, ...this._items.get(domObject)]) { + let ids = this._renderedNodes.get(item); + if (!ids) { + ids = new Set(); + this._renderedNodes.set(item, ids); + } + ids.add(id); } } if (!this._locations.get(domObject)) { @@ -1016,10 +1061,10 @@ export class DomReconciliationEngine { } private _diffObjectAssociateChildrenMap( objectA: GenericDomObject, - objectIdsB: DomObjectID[], + objectIdsB: Set, ): Map { const map = new Map(); - if (!objectIdsB.length) { + if (!objectIdsB.size) { return map; } const allChildrenA: GenericDomObject[] = [objectA]; @@ -1034,9 +1079,10 @@ export class DomReconciliationEngine { } const allChildrenB: DomObjectID[] = [...objectIdsB]; for (const id of allChildrenB) { - const domObject = this._objects[id]; - if (domObject?.children) { - for (const id of domObject.children) { + const objB = this._objects[id]; + this._rendererTreated.delete(objB.object); + if (objB?.children) { + for (const id of objB.children) { if (this._objects[id] && !this._renderedIds.has(id)) { allChildrenB.push(id); } @@ -1220,7 +1266,7 @@ export class DomReconciliationEngine { private _addLocations( domObject: GenericDomObject, locations: Map, - from: Map>, + from: Map>, ): Array { const allItems: Array = []; const items = from.get(domObject); @@ -1234,7 +1280,7 @@ export class DomReconciliationEngine { const nodes = locations.get(domObject); if (nodes) { - this._locations.set(domObject, nodes ? nodes : []); + this._locations.set(domObject, nodes ? Array.from(nodes) : []); for (const node of nodes) { if (!allItems.includes(node)) { allItems.push(node); diff --git a/packages/plugin-dom-layout/src/ZoneDomObjectRenderer.ts b/packages/plugin-dom-layout/src/ZoneDomObjectRenderer.ts index e9ae53a91..b78fc07c3 100644 --- a/packages/plugin-dom-layout/src/ZoneDomObjectRenderer.ts +++ b/packages/plugin-dom-layout/src/ZoneDomObjectRenderer.ts @@ -4,15 +4,23 @@ import { } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; import { NodeRenderer } from '../../plugin-renderer/src/NodeRenderer'; import { ZoneNode } from '../../plugin-layout/src/ZoneNode'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class ZoneDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; engine: DomObjectRenderingEngine; predicate = ZoneNode; - async render(node: ZoneNode): Promise { - return { - children: node.children().filter(child => !node.hidden.get(child)), - }; + async render(node: ZoneNode, worker: RenderingEngineWorker): Promise { + const children = node.children(); + const domObject: DomObject = { children: [] }; + for (let index = 0, len = children.length; index < len; index++) { + const child = children[index]; + if (!node.hidden?.[child.id]) { + domObject.children.push(child); + } + worker.depends(child, node); + } + return domObject; } } diff --git a/packages/plugin-dom-layout/test/DomLayout.test.ts b/packages/plugin-dom-layout/test/DomLayout.test.ts index 2d9056ef8..c0ea0d0ed 100644 --- a/packages/plugin-dom-layout/test/DomLayout.test.ts +++ b/packages/plugin-dom-layout/test/DomLayout.test.ts @@ -45,7 +45,8 @@ import { InlineNode } from '../../plugin-inline/src/InlineNode'; import { Attributes } from '../../plugin-xml/src/Attributes'; import { parseElement } from '../../utils/src/configuration'; import { Html } from '../../plugin-html/src/Html'; -import { flat } from '../../utils/src/utils'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; +import { ChangesLocations } from '../../core/src/Memory/Memory'; const container = document.createElement('div'); container.classList.add('container'); @@ -1198,13 +1199,14 @@ describe('DomLayout', () => { stepFunction: async editor => { const layout = editor.plugins.get(Layout); const domLayout = layout.engines.dom as DomLayoutEngine; - domLayout.components - .get('editable')[0] - .children()[0] - .children()[0] - .remove(); - document.getSelection().removeAllRanges(); - await domLayout.redraw(); + await editor.execCommand(() => { + domLayout.components + .get('editable')[0] + .children()[0] + .children()[0] + .remove(); + document.getSelection().removeAllRanges(); + }); }, contentAfter: '

b

', }); @@ -1232,7 +1234,7 @@ describe('DomLayout', () => { target.ownerDocument.getSelection().removeAllRanges(); const domLayoutEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - domLayoutEngine.redraw(); + domLayoutEngine.redraw({ add: [], move: [], remove: [], update: [] }); const domSelection = target.ownerDocument.getSelection(); expect(domSelection.anchorNode).to.deep.equal(null); @@ -1263,14 +1265,16 @@ describe('DomLayout', () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const abc = engine.getNodes(body.firstChild.firstChild); const def = engine.getNodes(body.lastChild.firstChild); - editor.selection.set({ - anchorNode: abc[1], - anchorPosition: RelativePosition.BEFORE, - focusNode: def[2], - direction: Direction.FORWARD, - focusPosition: RelativePosition.BEFORE, + + await editor.execCommand(() => { + editor.selection.set({ + anchorNode: abc[1], + anchorPosition: RelativePosition.BEFORE, + focusNode: def[2], + direction: Direction.FORWARD, + focusPosition: RelativePosition.BEFORE, + }); }); - await engine.redraw(); const redrawedBody = container.getElementsByTagName('jw-editor')[0]; const domSelection = target.ownerDocument.getSelection(); @@ -1315,14 +1319,16 @@ describe('DomLayout', () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const img = engine.getNodes(body.firstChild.firstChild)[0]; const abc = engine.getNodes(body.lastChild.firstChild); - editor.selection.set({ - anchorNode: img, - anchorPosition: RelativePosition.BEFORE, - focusNode: abc[2], - direction: Direction.FORWARD, - focusPosition: RelativePosition.BEFORE, + + await editor.execCommand(() => { + editor.selection.set({ + anchorNode: img, + anchorPosition: RelativePosition.BEFORE, + focusNode: abc[2], + direction: Direction.FORWARD, + focusPosition: RelativePosition.BEFORE, + }); }); - await engine.redraw(); const redrawedBody = container.getElementsByTagName('jw-editor')[0]; const domSelection = target.ownerDocument.getSelection(); @@ -1365,14 +1371,15 @@ describe('DomLayout', () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const abc = engine.getNodes(body.firstChild.firstChild); const def = engine.getNodes(body.lastChild.firstChild); - editor.selection.set({ - anchorNode: def[1], - anchorPosition: RelativePosition.AFTER, - focusNode: abc[1], - direction: Direction.BACKWARD, - focusPosition: RelativePosition.BEFORE, + await editor.execCommand(() => { + editor.selection.set({ + anchorNode: def[1], + anchorPosition: RelativePosition.AFTER, + focusNode: abc[1], + direction: Direction.BACKWARD, + focusPosition: RelativePosition.BEFORE, + }); }); - await engine.redraw(); const domSelection = target.ownerDocument.getSelection(); const redrawedBody = container.getElementsByTagName('jw-editor')[0]; @@ -1415,22 +1422,29 @@ describe('DomLayout', () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const div = engine.getNodes(container.querySelector('div'))[0]; - const parent = div.parent; - div.remove(); - - await engine.redraw(parent); + await editor.execCommand(() => { + div.remove(); + }); expect(container.innerHTML).to.equal(''); - editor.selection.set({ - anchorNode: div.descendants(CharNode)[0], - anchorPosition: RelativePosition.AFTER, - focusNode: div.descendants(CharNode)[0], - direction: Direction.BACKWARD, - focusPosition: RelativePosition.AFTER, - }); + let hasError = false; + await editor + .execCommand(() => { + editor.selection.set({ + anchorNode: div.descendants(CharNode)[0], + anchorPosition: RelativePosition.AFTER, + focusNode: div.descendants(CharNode)[0], + direction: Direction.BACKWARD, + focusPosition: RelativePosition.AFTER, + }); + }) + .catch(error => { + hasError = true; + expect(error.message).to.include('selection'); + }); + expect(hasError).to.equal(true); - await engine.redraw(); const domSelection = target.ownerDocument.getSelection(); expect(domSelection.anchorNode).to.equal(null); @@ -1447,19 +1461,18 @@ describe('DomLayout', () => { const element = new VElement({ htmlTag: 'div' }); const p = new VElement({ htmlTag: 'p' }); - element.append(p); - editor.selection.set({ - anchorNode: p, - anchorPosition: RelativePosition.INSIDE, - focusNode: p, - direction: Direction.BACKWARD, - focusPosition: RelativePosition.INSIDE, + await editor.execCommand(() => { + element.append(p); + editor.selection.set({ + anchorNode: p, + anchorPosition: RelativePosition.INSIDE, + focusNode: p, + direction: Direction.BACKWARD, + focusPosition: RelativePosition.INSIDE, + }); }); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(); - const domSelection = target.ownerDocument.getSelection(); expect(domSelection.anchorNode).to.equal(null); @@ -1473,7 +1486,10 @@ describe('DomLayout', () => { static id = DomObjectRenderingEngine.id; engine: DomObjectRenderingEngine; predicate = CustomNode; - async render(node: CustomNode): Promise { + async render( + node: CustomNode, + worker: RenderingEngineWorker, + ): Promise { const domObject: DomObjectFragment = { children: [ { @@ -1487,9 +1503,9 @@ describe('DomLayout', () => { }, ], }; - this.engine.locate([node], domObject.children[0] as DomObjectText); - this.engine.locate([node], domObject.children[1] as DomObjectElement); - this.engine.locate([node], domObject.children[2] as DomObjectText); + worker.locate([node], domObject.children[0] as DomObjectText); + worker.locate([node], domObject.children[1] as DomObjectElement); + worker.locate([node], domObject.children[2] as DomObjectText); return domObject; } } @@ -1515,18 +1531,19 @@ describe('DomLayout', () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - custom.before(new CharNode({ char: 'X' })); - custom.after(new CharNode({ char: 'Y' })); - editor.selection.set({ - anchorNode: custom, - anchorPosition: RelativePosition.AFTER, - focusNode: custom, - direction: Direction.BACKWARD, - focusPosition: RelativePosition.AFTER, - }); - document.getSelection().removeAllRanges(); - await engine.redraw(custom.parent, custom.previous(), custom.next()); + + await editor.execCommand(() => { + custom.before(new CharNode({ char: 'X' })); + custom.after(new CharNode({ char: 'Y' })); + editor.selection.set({ + anchorNode: custom, + anchorPosition: RelativePosition.AFTER, + focusNode: custom, + direction: Direction.BACKWARD, + focusPosition: RelativePosition.AFTER, + }); + }); const domEditor = container.getElementsByTagName('jw-editor')[0]; @@ -1536,8 +1553,8 @@ describe('DomLayout', () => { expect(childNodes.indexOf(domSelection.anchorNode)).to.deep.equal(3); expect(domSelection.anchorOffset).to.deep.equal(1); - // redraw without changes - await engine.redraw(custom); + // redraw without real changes + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); childNodes = [...domEditor.childNodes] as Node[]; domSelection = target.ownerDocument.getSelection(); @@ -1569,9 +1586,9 @@ describe('DomLayout', () => { const renderedText = await renderer.render( 'dom/object', - textNodes[0], + textNodes[1], ); - expect(renderedText).to.deep.equal({ text: 'abc' }); + expect(renderedText).to.deep.equal({ text: 'b' }); }, contentAfter: 'a[b]c', }); @@ -1607,6 +1624,28 @@ describe('DomLayout', () => { contentAfter: 'a[b]c', }); }); + it('should render text and linebreak with format', async () => { + await testEditor(BasicEditor, { + contentBefore: 'a[b
c]d', + stepFunction: async (editor: JWEditor) => { + const domEngine = editor.plugins.get(Layout).engines.dom; + const editable = domEngine.components.get('editable')[0]; + const renderer = editor.plugins.get(Renderer); + const br = editable.children()[2]; + await editor.execCommand(() => { + new BoldFormat().applyTo(br); + return editor.execCommand('toggleFormat', { + FormatClass: BoldFormat, + }); + }); + expect(await renderer.render('dom/object', br)).to.deep.equal({ + tag: 'B', + children: [{ tag: 'BR' }], + }); + }, + contentAfter: 'a[b
c]
d', + }); + }); }); describe('redraw with minimum mutation', () => { let observer: MutationObserver; @@ -1627,47 +1666,6 @@ describe('DomLayout', () => { observer.disconnect(); }); describe('all', () => { - it('should redraw text and add format', async () => { - await testEditor(BasicEditor, { - contentBefore: 'a[b]c', - stepFunction: async (editor: JWEditor) => { - await nextTick(); - mutationNumber = 0; - - await editor.execCommand('toggleFormat', { - FormatClass: BoldFormat, - }); - - const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const editable = domEngine.components.get('editable')[0]; - - const domEditable = domEngine.getDomNodes(editable)[0] as Element; - expect(domEditable.innerHTML).to.equal('abc'); - - const renderer = editor.plugins.get(Renderer); - const rendered = await renderer.render('dom/object', editable); - const textNodes = editable.children(); - - expect( - rendered && 'children' in rendered && rendered.children, - ).to.deep.equal(textNodes); - - expect(mutationNumber).to.equal(4, 'add , move , 2 toolbar update'); - - const renderedText1 = await renderer.render('dom/object', textNodes[1]); - expect(renderedText1).to.deep.equal({ - tag: 'B', - children: [ - { - tag: 'I', - children: [{ text: 'b' }], - }, - ], - }); - }, - contentAfter: 'a[b]c', - }); - }); it('should redraw all with new item', async () => { const Component: ComponentDefinition = { id: 'test', @@ -1693,13 +1691,15 @@ describe('DomLayout', () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const b = engine.getNodes(container.getElementsByTagName('p')[0].firstChild)[1]; const area = new VElement({ htmlTag: 'area' }); - b.after(area); - await nextTick(); mutationNumber = 0; + await editor.execCommand(() => { + b.after(area); + expect(container.innerHTML).to.equal( + '

abc

def

', + ); + }); - expect(container.innerHTML).to.equal('

abc

def

'); - await engine.redraw(); expect(container.innerHTML).to.equal( '

abc

def

', ); @@ -1734,16 +1734,14 @@ describe('DomLayout', () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const b = engine.getNodes(container.getElementsByTagName('p')[0].firstChild)[1]; const area = new VElement({ htmlTag: 'area' }); - b.after(area); - expect(container.innerHTML).to.equal( - '

abc

def

', - ); - - await nextTick(); mutationNumber = 0; - - await engine.redraw(); + await editor.execCommand(() => { + b.after(area); + expect(container.innerHTML).to.equal( + '

abc

def

', + ); + }); expect(container.innerHTML).to.equal( '

abc

def

', ); @@ -1781,7 +1779,13 @@ describe('DomLayout', () => { let hasFail = false; const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw().catch(e => { + const changes: ChangesLocations = { + add: [], + move: [], + remove: [], + update: engine.root.descendants().map(node => [node, ['id']]), + }; + await engine.redraw(changes).catch(e => { expect(e.message).to.include('Impossible'); hasFail = true; }); @@ -1817,15 +1821,18 @@ describe('DomLayout', () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const b = engine.getNodes(container.getElementsByTagName('p')[0].firstChild)[1]; const area = new VElement({ htmlTag: 'area' }); - b.after(area); target.remove(); - await nextTick(); + mutationNumber = 0; + await editor.execCommand(() => { + b.after(area); + expect(container.innerHTML).to.equal( + '

abc

def

', + ); + }); - expect(container.innerHTML).to.equal('

abc

def

'); - await engine.redraw(); expect(container.innerHTML).to.equal( '

abc

def

', ); @@ -1881,16 +1888,15 @@ describe('DomLayout', () => { const divDom = container.getElementsByTagName('div')[1]; const div = engine.getNodes(divDom)[0]; const area = new VElement({ htmlTag: 'area' }); - div.after(area); - - expect(container.innerHTML).to.equal( - '

abc

def

', - ); - await nextTick(); mutationNumber = 0; + await editor.execCommand(() => { + div.after(area); + expect(container.innerHTML).to.equal( + '

abc

def

', + ); + }); - await engine.redraw(); expect(container.innerHTML).to.equal( '

abc

def

', ); @@ -1943,18 +1949,18 @@ describe('DomLayout', () => { const divDom = container.getElementsByTagName('div')[1]; const p = engine.getNodes(divDom)[0]; const area = new VElement({ htmlTag: 'area' }); - p.after(area); divDom.remove(); - - expect(container.innerHTML).to.equal( - '

abc

def

', - ); - await nextTick(); + mutationNumber = 0; + await editor.execCommand(() => { + p.after(area); + expect(container.innerHTML).to.equal( + '

abc

def

', + ); + }); - await engine.redraw(); expect(container.innerHTML).to.equal( '

abc

def

', ); @@ -2001,15 +2007,18 @@ describe('DomLayout', () => { const divDom = container.getElementsByTagName('div')[0]; const p = engine.getNodes(divDom)[0]; const area = new VElement({ htmlTag: 'area' }); - p.after(area); divDom.remove(); - await nextTick(); + mutationNumber = 0; + await editor.execCommand(() => { + p.after(area); + expect(container.innerHTML).to.equal( + '

abc

def

', + ); + }); - expect(container.innerHTML).to.equal('

abc

def

'); - await engine.redraw(); expect(container.innerHTML).to.equal( '

abc

def

', ); @@ -2048,7 +2057,13 @@ describe('DomLayout', () => { let hasFail = false; const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw().catch(e => { + const changes: ChangesLocations = { + add: [], + move: [], + remove: [], + update: engine.root.descendants().map(node => [node, ['id']]), + }; + await engine.redraw(changes).catch(e => { expect(e.message).to.include('Impossible'); hasFail = true; }); @@ -2111,7 +2126,13 @@ describe('DomLayout', () => { let hasFail = false; const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw().catch(e => { + const changes: ChangesLocations = { + add: [], + move: [], + remove: [], + update: engine.root.descendants().map(node => [node, ['id']]), + }; + await engine.redraw(changes).catch(e => { expect(e.message).to.include('Impossible'); hasFail = true; }); @@ -2123,125 +2144,380 @@ describe('DomLayout', () => { await editor.stop(); }); }); - describe('text', () => { - let editor: JWEditor; - let div: VNode; - beforeEach(async () => { - editor = new JWEditor(); - editor.load(Html); - editor.load(Char); - editor.load(Bold); - editor.load(Italic); - editor.load(Underline); - editor.load(LineBreak); - editor.configure(DomLayout, { - location: [target, 'replace'], - components: [ - { - id: 'aaa', - async render(editor: JWEditor): Promise { - [div] = await editor.plugins - .get(Parser) - .parse('text/html', '

abcdef

123456

'); - return [div]; - }, - }, - ], - componentZones: [['aaa', ['main']]], - }); - await editor.start(); - }); - afterEach(async () => { - await editor.stop(); - }); - it('should delete the last characters in a paragraph', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; - - const p = div.firstChild(); - const f = p.children()[5]; - const e = p.children()[4]; - f.remove(); - e.remove(); - - await nextTick(); - mutationNumber = 0; - - await engine.redraw(p, f, e); + describe('BasicEditor', () => { + it('should redraw text and add format', async () => { + await testEditor(BasicEditor, { + contentBefore: 'a[b]c', + stepFunction: async (editor: JWEditor) => { + await nextTick(); + mutationNumber = 0; - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

abcd

123456

', - ); + await editor.execCommand('toggleFormat', { + FormatClass: BoldFormat, + }); - expect(mutationNumber).to.equal(1); - }); - it('should delete the last characters in a paragraph (in VNode and Dom)', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; + const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; + const editable = domEngine.components.get('editable')[0]; - const p = div.firstChild(); - const f = p.children()[5]; - const e = p.children()[4]; - f.remove(); - e.remove(); - text.textContent = 'abcd'; + const domEditable = domEngine.getDomNodes(editable)[0] as Element; + expect(domEditable.innerHTML).to.equal('abc'); - await nextTick(); - mutationNumber = 0; + const renderer = editor.plugins.get(Renderer); + const rendered = await renderer.render('dom/object', editable); + const textNodes = editable.children(); - await engine.redraw(p, f, e); + expect( + rendered && 'children' in rendered && rendered.children, + ).to.deep.equal(textNodes); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

abcd

123456

', - ); + expect(mutationNumber).to.equal(5, 'add , move , 3 toolbar update'); - expect(mutationNumber).to.equal(0); + const renderedText1 = await renderer.render('dom/object', textNodes[1]); + expect(renderedText1).to.deep.equal({ + tag: 'B', + children: [ + { + tag: 'I', + children: [{ text: 'b' }], + }, + ], + }); + }, + contentAfter: 'a[b]c', + }); }); - it('should delete the first characters in a paragraph', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; - - const p = div.firstChild(); - const a = p.children()[0]; - const b = p.children()[1]; - a.remove(); - b.remove(); - - await nextTick(); - mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes); - - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

cdef

123456

', - ); - - expect(mutationNumber).to.equal(1); + it('should remove the first char in an execBatch', async () => { + await testEditor(BasicEditor, { + contentBefore: '
ab[]
', + stepFunction: async editor => { + mutationNumber = 0; + await editor.execCommand(async () => { + const layout = editor.plugins.get(Layout); + const domEngine = layout.engines.dom; + domEngine.components + .get('editable')[0] + .firstLeaf() + .remove(); + }); + expect(document.querySelector('jw-test').innerHTML).to.equal( + '
b
', + ); + expect(mutationNumber).to.equal( + 2, + 'update text, update toolbar history button', + ); + }, + contentAfter: '
b[]
', + }); }); - it('should delete characters in a paragraph', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; + it('should use command delete to merge a paragraph into an empty paragraph', async () => { + await testEditor(BasicEditor, { + contentBefore: '

[]

abc

', + stepFunction: async editor => { + mutationNumber = 0; + await editor.execCommand('deleteForward'); + expect(document.querySelector('jw-test').innerHTML).to.equal('

abc

'); + expect(mutationNumber).to.equal( + 3, + 'remove

, remove
, update toolbar history button', + ); + }, + contentAfter: '

[]abc

', + }); + }); + it('should set, then unset the background color of two characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc]d

', + stepFunction: async editor => { + mutationNumber = 0; + await editor.execCommand('colorBackground', { color: 'red' }); + expect(document.querySelector('jw-test').innerHTML).to.equal( + '

abcd

', + ); + expect(mutationNumber).to.equal( + 6, + 'update text, add , add text, add text, 2 update toolbar', + ); + mutationNumber = 0; + await editor.execCommand('uncolorBackground'); + expect(document.querySelector('jw-test').innerHTML).to.equal('

abcd

'); + expect(mutationNumber).to.equal( + 3, + 'remove , add text, update toolbar', + ); + }, + contentAfter: '

a[bc]d

', + }); + }); + it('should remove some attributes on everything', async () => { + await testEditor(BasicEditor, { + contentBefore: + '

[abcde]

', + stepFunction: async editor => { + await nextTick(); + mutationNumber = 0; + await editor.execCommand('uncolorBackground'); + expect(document.querySelector('jw-test').innerHTML).to.equal( + '

abcde

', + ); + expect(mutationNumber).to.equal( + 6, + 'remove 3 formats + remove 2 empty styles, update toolbar', + ); + }, + contentAfter: '

[abcde]

', + }); + }); + it('should render linebreak with format', async () => { + await testEditor(BasicEditor, { + contentBefore: 'a[b
c]d', + stepFunction: async (editor: JWEditor) => { + const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; + const editable = domEngine.components.get('editable')[0]; + + mutationNumber = 0; + + const renderer = editor.plugins.get(Renderer); + const br = editable.children()[2]; + + await editor.execCommand(() => { + new BoldFormat().applyTo(br); + }); + + expect(await renderer.render('dom/object', br)).to.deep.equal({ + tag: 'B', + children: [{ tag: 'BR' }], + }); + expect(mutationNumber).to.equal( + 3, + 'add b, move br, update toolbar history button', + ); + }, + contentAfter: 'a[b
c]d', + }); + }); + it('should render linebreak with format then the siblings char', async () => { + await testEditor(BasicEditor, { + contentBefore: 'a[b
c]d', + stepFunction: async (editor: JWEditor) => { + const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; + const editable = domEngine.components.get('editable')[0]; + + const renderer = editor.plugins.get(Renderer); + const br = editable.children()[2]; + + mutationNumber = 0; + + await editor.execCommand(() => { + new BoldFormat().applyTo(br); + }); + + const domEditable = domEngine.getDomNodes(editable)[0] as Element; + expect(domEditable.innerHTML).to.equal('ab
cd'); + expect(await renderer.render('dom/object', br)).to.deep.equal({ + tag: 'B', + children: [{ tag: 'BR' }], + }); + expect(mutationNumber).to.equal( + 3, + 'remove br, add b, update toolbar history button', + ); + + mutationNumber = 0; + + await editor.execCommand('toggleFormat', { + FormatClass: BoldFormat, + }); + + expect(domEditable.innerHTML).to.equal('ab
c
d'); + expect(await renderer.render('dom/object', br)).to.deep.equal({ + tag: 'B', + children: [{ tag: 'BR' }], + }); + expect(mutationNumber).to.equal( + 10, + 'change text, add b, create text, add text, remove br, create text, add text, change text, 2 toolbar changes', + ); + }, + contentAfter: 'a[b
c]
d', + }); + }); + it('should render text and linebreak with format', async () => { + await testEditor(BasicEditor, { + contentBefore: 'a[b
c]d', + stepFunction: async (editor: JWEditor) => { + const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; + const editable = domEngine.components.get('editable')[0]; + + const renderer = editor.plugins.get(Renderer); + const br = editable.children()[2]; + + await editor.execCommand(() => { + new BoldFormat().applyTo(br); + + mutationNumber = 0; + + return editor.execCommand('toggleFormat', { + FormatClass: BoldFormat, + }); + }); + + const domEditable = domEngine.getDomNodes(editable)[0] as Element; + expect(domEditable.innerHTML).to.equal('ab
c
d'); + expect(await renderer.render('dom/object', br)).to.deep.equal({ + tag: 'B', + children: [{ tag: 'BR' }], + }); + expect(mutationNumber).to.equal( + 11, + 'change text, add b, crete text, add text, move br, create text, add text, change text, 3 toolbar changes', + ); + }, + contentAfter: 'a[b
c]
d', + }); + }); + it('should render a linebreak with format between formatted char', async () => { + await testEditor(BasicEditor, { + contentBefore: 'a[b
c]d', + stepFunction: async (editor: JWEditor) => { + const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; + const editable = domEngine.components.get('editable')[0]; + + const br = editable.children()[2]; + + mutationNumber = 0; + + await editor.execCommand(() => { + new BoldFormat().applyTo(br); + }); + + const domEditable = domEngine.getDomNodes(editable)[0] as Element; + expect(domEditable.innerHTML).to.equal('ab
c
d'); + + const renderer = editor.plugins.get(Renderer); + expect(await renderer.render('dom/object', br)).to.deep.equal({ + tag: 'B', + children: [{ tag: 'BR' }], + }); + expect(mutationNumber).to.equal( + 6, + 'remove second b, move br, move text, update toolbar history button', + ); + }, + contentAfter: 'a[b
c]
d', + }); + }); + }); + describe('text', () => { + let editor: JWEditor; + let div: VNode; + beforeEach(async () => { + editor = new JWEditor(); + editor.load(Html); + editor.load(Char); + editor.load(Bold); + editor.load(Italic); + editor.load(Underline); + editor.load(LineBreak); + editor.configure(DomLayout, { + location: [target, 'replace'], + components: [ + { + id: 'aaa', + async render(editor: JWEditor): Promise { + [div] = await editor.plugins + .get(Parser) + .parse('text/html', '

abcdef

123456

'); + return [div]; + }, + }, + ], + componentZones: [['aaa', ['main']]], + }); + await editor.start(); + mutationNumber = 0; + }); + afterEach(async () => { + await editor.stop(); + }); + it('should delete the last characters in a paragraph', async () => { const pDom = container.querySelector('p'); const text = pDom.firstChild; const p = div.firstChild(); - const c = p.children()[2]; - const d = p.children()[3]; - c.remove(); - d.remove(); + const f = p.children()[5]; + const e = p.children()[4]; + + await editor.execCommand(() => { + f.remove(); + e.remove(); + }); + + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

abcd

123456

', + ); + + expect(mutationNumber).to.equal(1); + }); + it('should delete the last characters in a paragraph (in VNode and Dom)', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; + text.textContent = 'abcd'; await nextTick(); + mutationNumber = 0; + await editor.execCommand(() => { + const p = div.firstChild(); + const f = p.children()[5]; + const e = p.children()[4]; + f.remove(); + e.remove(); + }); + + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

abcd

123456

', + ); - await engine.redraw(p, ...p.childVNodes); + expect(mutationNumber).to.equal(0); + }); + it('should delete the first characters in a paragraph', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; + + mutationNumber = 0; + await editor.execCommand(() => { + const p = div.firstChild(); + const a = p.children()[0]; + const b = p.children()[1]; + a.remove(); + b.remove(); + }); + + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

cdef

123456

', + ); + + expect(mutationNumber).to.equal(1); + }); + it('should delete characters in a paragraph', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; + + mutationNumber = 0; + await editor.execCommand(() => { + const p = div.firstChild(); + const c = p.children()[2]; + const d = p.children()[3]; + c.remove(); + d.remove(); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); @@ -2252,22 +2528,20 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(1); }); it('should delete characters in a paragraph with split in DOM', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild as Text; - const p = div.firstChild(); - const c = p.children()[2]; - const d = p.children()[3]; - c.remove(); - d.remove(); - const text2 = text.splitText(3); - await nextTick(); - mutationNumber = 0; - await engine.redraw(p, ...p.childVNodes); + mutationNumber = 0; + await editor.execCommand(() => { + const p = div.firstChild(); + const c = p.children()[2]; + const d = p.children()[3]; + c.remove(); + d.remove(); + }); expect(container.innerHTML).to.equal( '

abef

123456

', @@ -2280,25 +2554,23 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(2, 'abc => ab ; def => ef'); }); it('should delete characters in a paragraph with split in DOM and removed Text', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild as Text; - const p = div.firstChild(); - const b = p.children()[1]; - const c = p.children()[2]; - const d = p.children()[3]; - b.remove(); - c.remove(); - d.remove(); - const text2 = text.splitText(2); const text3 = text2.splitText(2); - await nextTick(); - mutationNumber = 0; - await engine.redraw(p, ...p.childVNodes); + mutationNumber = 0; + await editor.execCommand(() => { + const p = div.firstChild(); + const b = p.children()[1]; + const c = p.children()[2]; + const d = p.children()[3]; + b.remove(); + c.remove(); + d.remove(); + }); expect(container.innerHTML).to.equal( '

aef

123456

', @@ -2309,22 +2581,20 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(2, 'ab => a ; remove cd'); }); it('should delete characters in a paragraph with split in DOM', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild as Text; - const p = div.firstChild(); - const c = p.children()[2]; - const d = p.children()[3]; - c.remove(); - d.remove(); - const text2 = text.splitText(3); - await nextTick(); - mutationNumber = 0; - await engine.redraw(p, ...p.childVNodes); + mutationNumber = 0; + await editor.execCommand(() => { + const p = div.firstChild(); + const c = p.children()[2]; + const d = p.children()[3]; + c.remove(); + d.remove(); + }); expect(container.innerHTML).to.equal( '

abef

123456

', @@ -2337,22 +2607,19 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(2, 'abc => ab ; def => ef'); }); it('should replace a character in a paragraph', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; - const p = div.firstChild(); - const c = p.children()[2]; - const d = p.children()[3]; - const x = new CharNode({ char: 'x' }); - c.after(x); - c.remove(); - d.remove(); - - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes); + await editor.execCommand(() => { + const p = div.firstChild(); + const c = p.children()[2]; + const d = p.children()[3]; + const x = new CharNode({ char: 'x' }); + c.after(x); + c.remove(); + d.remove(); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); @@ -2363,22 +2630,19 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(1); }); it('should delete the last character and replace ce previous in a paragraph', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; - const p = div.firstChild(); - const f = p.children()[5]; - const e = p.children()[4]; - const z = new CharNode({ char: 'z' }); - e.before(z); - f.remove(); - e.remove(); - - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes); + await editor.execCommand(() => { + const p = div.firstChild(); + const f = p.children()[5]; + const e = p.children()[4]; + const z = new CharNode({ char: 'z' }); + e.before(z); + f.remove(); + e.remove(); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); @@ -2389,7 +2653,6 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(1); }); it('should replace a character in a paragraph with same chars', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; @@ -2398,29 +2661,29 @@ describe('DomLayout', () => { const x = new CharNode({ char: 'x' }); const x2 = new CharNode({ char: 'x' }); const x3 = new CharNode({ char: 'x' }); - c.after(x); - x.after(x2); - x2.after(x3); - await engine.redraw(p, ...p.childVNodes); + await editor.execCommand(() => { + c.after(x); + x.after(x2); + x2.after(x3); + }); expect(container.innerHTML).to.equal( '

abcxxxdef

123456

', ); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - x2.remove(); - - await nextTick(); mutationNumber = 0; + await editor.execCommand(() => { + x2.remove(); + }); - await engine.redraw(p, ...p.childVNodes); - - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); expect(container.innerHTML).to.equal( '

abcxxdef

123456

', ); - + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

(2)'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text (2)'); expect(mutationNumber).to.equal(1); }); it('should add char identique to the previous in a paragraph', async () => { @@ -2440,18 +2703,16 @@ describe('DomLayout', () => { }); await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const p = div.firstChild(); const add0 = new CharNode({ char: '0' }); - p.children()[4].after(add0); - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes); + await editor.execCommand(() => { + p.children()[4].after(add0); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); @@ -2475,18 +2736,16 @@ describe('DomLayout', () => { }); await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const p = div.firstChild(); const char0 = p.children()[3]; - char0.remove(); - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes); + await editor.execCommand(() => { + char0.remove(); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); @@ -2494,19 +2753,16 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(1); }); it('should merge 2 paragraphs which contains simple text', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const p1 = div.firstChild(); const p2 = div.lastChild(); - const chars = p2.children(); - p2.mergeWith(p1); - await nextTick(); mutationNumber = 0; - - await engine.redraw(div, p1, ...chars, p2); + await editor.execCommand(() => { + p2.mergeWith(p1); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); @@ -2519,18 +2775,15 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(3, 'abcdef => abcdef123456 ; remove p & text'); }); it('should remove paragraphs content', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const p1 = div.firstChild(); - const chars = p1.children(); - p1.empty(); - await nextTick(); mutationNumber = 0; - - await engine.redraw(p1, ...chars); + await editor.execCommand(() => { + p1.empty(); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(!!text.parentNode).to.equal(false); @@ -2541,28 +2794,25 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(2, 'add
, remove text'); }); it('should merge to paragraphs which contains br and text', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text2 = pDom.nextElementSibling.firstChild; const p1 = div.firstChild(); - const chars = p1.children(); - p1.empty(); - await engine.redraw(p1, ...chars); + await editor.execCommand(() => { + p1.empty(); + }); expect(container.innerHTML).to.equal( '


123456

', ); const p2 = div.lastChild(); - const chars2 = p2.children(); - p2.mergeWith(p1); - await nextTick(); mutationNumber = 0; - - await engine.redraw(div, p1, ...chars2, p2); + await editor.execCommand(() => { + p2.mergeWith(p1); + }); expect(container.innerHTML).to.equal( '

123456

', @@ -2573,21 +2823,21 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(4, 'add text; remove
; remove

& text'); }); it('should merge to paragraphs with selection', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const p1 = div.firstChild(); const p2 = div.lastChild(); - editor.selection.setAt(p2.firstChild(), RelativePosition.BEFORE); - const chars = p2.children(); - p2.mergeWith(p1); - await nextTick(); mutationNumber = 0; + await editor.execCommand(() => { + editor.selection.setAt(p2.firstChild(), RelativePosition.BEFORE); + p2.mergeWith(p1); + }); - await engine.redraw(div, p1, ...chars, p2); - + expect(container.innerHTML).to.equal( + '

abcdef123456

', + ); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); renderTextualSelection(); @@ -2598,19 +2848,17 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(3, 'update text; remove

& text'); }); it('should add characters at the end of a paragraph', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const p = div.firstChild(); const g = new CharNode({ char: 'g' }); const h = new CharNode({ char: 'h' }); - p.append(g, h); - - await nextTick(); - mutationNumber = 0; - await engine.redraw(p, g, h); + mutationNumber = 0; + await editor.execCommand(() => { + p.append(g, h); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); @@ -2621,7 +2869,6 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(1, 'update text'); }); it('should split a paragraph', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; @@ -2629,13 +2876,12 @@ describe('DomLayout', () => { const f = p.children()[5]; const e = p.children()[4]; const newP = new VElement({ htmlTag: 'P' }); - newP.append(e, f); - p.after(newP); - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes, newP, ...newP.childVNodes); + await editor.execCommand(() => { + newP.append(e, f); + p.after(newP); + }); expect(container.innerHTML).to.equal( '

abcd

ef

123456

', @@ -2646,7 +2892,6 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(2, 'update text; add

& text'); }); it('should make bold all chars', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const pDom2 = pDom.nextElementSibling; @@ -2654,17 +2899,16 @@ describe('DomLayout', () => { const p = div.firstChild(); const p2 = div.lastChild(); - for (const char of p.children(InlineNode)) { - new BoldFormat().applyTo(char); - } - for (const char of p2.children(InlineNode)) { - new BoldFormat().applyTo(char); - } - await nextTick(); mutationNumber = 0; - - await engine.redraw(...p.children(), ...p2.children()); + await editor.execCommand(() => { + for (const char of p.children(InlineNode)) { + new BoldFormat().applyTo(char); + } + for (const char of p2.children(InlineNode)) { + new BoldFormat().applyTo(char); + } + }); expect(container.innerHTML).to.equal( '

abcdef

123456

', @@ -2676,19 +2920,16 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(4, 'add bold & text; add bold & text'); }); it('should make bold p', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const p = div.firstChild(); const p2 = div.lastChild(); - new BoldFormat().applyTo(p); - new BoldFormat().applyTo(p2); - - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, p2); + await editor.execCommand(() => { + new BoldFormat().applyTo(p); + new BoldFormat().applyTo(p2); + }); expect(container.innerHTML).to.equal( '

abcdef

123456

', @@ -2698,30 +2939,28 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(3, 'parent bold, move into bold'); }); it('should split a paragraph within a format node', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const p = div.firstChild(); - const p2 = div.lastChild(); - for (const char of p.children(InlineNode)) { - new BoldFormat().applyTo(char); - } - await engine.redraw(...p.children(), ...p2.children()); + await editor.execCommand(() => { + for (const char of p.children(InlineNode)) { + new BoldFormat().applyTo(char); + } + }); const dBold = pDom.firstChild; const f = p.children()[5]; const e = p.children()[4]; const newP = new VElement({ htmlTag: 'P' }); - newP.append(e, f); - p.after(newP); - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes, newP, ...newP.childVNodes); + await editor.execCommand(() => { + newP.append(e, f); + p.after(newP); + }); expect(container.innerHTML).to.equal( '

abcd

ef

123456

', @@ -2733,18 +2972,16 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(2, 'update text; add

& & text'); }); it('should add a linebreak in a paragraph', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const p = div.firstChild(); const lineBreak = new LineBreakNode(); - p.children()[2].after(lineBreak); - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes); + await editor.execCommand(() => { + p.children()[2].after(lineBreak); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); expect(pDom.firstChild === text).to.equal(true, 'Use same text'); @@ -2761,20 +2998,18 @@ describe('DomLayout', () => { const p = div.firstChild(); const lineBreak = new LineBreakNode(); - p.children()[2].after(lineBreak); - - await engine.redraw(p, ...p.childVNodes); - lineBreak.remove(); - - expect(container.innerHTML).to.equal( - '

abc
def

123456

', - ); + await editor.execCommand(() => { + p.children()[2].after(lineBreak); + }); - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...p.childVNodes); + await editor.execCommand(() => { + lineBreak.remove(); + expect(container.innerHTML).to.equal( + '

abc
def

123456

', + ); + }); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); @@ -2786,26 +3021,26 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(1, 'remove
'); - const marker = new MarkerNode(); - p.children()[3].after(marker); - const location = engine._domReconciliationEngine.getLocations(marker); - expect(location).to.deep.equal( - [pDom.lastChild, 1], - 'location in the second text node', - ); + await editor.execCommand(() => { + const marker = new MarkerNode(); + p.children()[3].after(marker); + const location = engine._domReconciliationEngine.getLocations(marker); + expect(location).to.deep.equal( + [pDom.lastChild, 1], + 'location in the second text node', + ); + }); }); it('should redraw a br', async () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const p = div.firstChild(); - const children = p.children(); - p.empty(); - await nextTick(); mutationNumber = 0; - - await engine.redraw(p, ...children); + await editor.execCommand(() => { + p.empty(); + }); expect(container.innerHTML).to.equal( '


123456

', @@ -2820,15 +3055,14 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(2, 'remove text; insert
'); const marker = new MarkerNode(); - p.prepend(marker); - - let location = engine._domReconciliationEngine.getLocations(marker); - expect(location).to.deep.equal([pDom, 0], 'location with a new marker'); - await nextTick(); mutationNumber = 0; + await editor.execCommand(() => { + p.prepend(marker); + }); - await engine.redraw(p); + let location = engine._domReconciliationEngine.getLocations(marker); + expect(location).to.deep.equal([pDom, 0], 'location with a new marker'); expect(container.innerHTML).to.equal( '


123456

', @@ -2847,13 +3081,12 @@ describe('DomLayout', () => { const p = div.firstChild(); const lineBreak = new LineBreakNode(); const d = p.children()[3]; - p.children()[2].after(lineBreak); - - editor.selection.setAt(d, RelativePosition.AFTER); - await engine.redraw(p, ...p.childVNodes); + await editor.execCommand(() => { + p.children()[2].after(lineBreak); + editor.selection.setAt(d, RelativePosition.AFTER); + }); - d.remove(); const pDom = container.querySelector('p'); const text = pDom.firstChild; const br = pDom.childNodes[1]; @@ -2862,7 +3095,9 @@ describe('DomLayout', () => { engine.markForRedraw(new Set([br, text2])); mutationNumber = 0; - const promise = engine.redraw(p, ...p.childVNodes); + const promise = editor.execCommand(() => { + d.remove(); + }); pDom.removeChild(br); @@ -2875,19 +3110,17 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(3); }); it('should add style on char', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const p = div.firstChild(); const children = p.children(); const bold = new BoldFormat(); - bold.applyTo(children[1]); - bold.applyTo(children[2]); - await nextTick(); mutationNumber = 0; - - await engine.redraw(children[1], children[2]); + await editor.execCommand(() => { + bold.applyTo(children[1]); + bold.applyTo(children[2]); + }); expect(container.innerHTML).to.equal( '

abcdef

123456

', @@ -2899,24 +3132,22 @@ describe('DomLayout', () => { ); }); it('should remove style on char', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const p = div.firstChild(); const children = p.children(); const bold = new BoldFormat(); - bold.applyTo(children[1]); - bold.applyTo(children[2]); - - await engine.redraw(children[1], children[2]); - children[1].modifiers.remove(bold); - children[2].modifiers.remove(bold); + await editor.execCommand(() => { + bold.applyTo(children[1]); + bold.applyTo(children[2]); + }); - await nextTick(); mutationNumber = 0; - - await engine.redraw(children[1], children[2]); + await editor.execCommand(() => { + children[1].modifiers.remove(bold); + children[2].modifiers.remove(bold); + }); expect(container.innerHTML).to.equal( '

abcdef

123456

', @@ -2928,132 +3159,6 @@ describe('DomLayout', () => { ); }); }); - describe('modifier', () => { - it('should render linebreak with format', async () => { - await testEditor(BasicEditor, { - contentBefore: 'a[b
c]d', - stepFunction: async (editor: JWEditor) => { - const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const editable = domEngine.components.get('editable')[0]; - - const renderer = editor.plugins.get(Renderer); - const br = editable.children()[2]; - new BoldFormat().applyTo(br); - - mutationNumber = 0; - - await domEngine.redraw(br); - - expect(await renderer.render('dom/object', br)).to.deep.equal({ - tag: 'B', - children: [{ tag: 'BR' }], - }); - expect(mutationNumber).to.equal(2, 'add b, move br'); - }, - contentAfter: 'a[b
c]d', - }); - }); - it('should render linebreak with format then the siblings char', async () => { - await testEditor(BasicEditor, { - contentBefore: 'a[b
c]d', - stepFunction: async (editor: JWEditor) => { - const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const editable = domEngine.components.get('editable')[0]; - - const renderer = editor.plugins.get(Renderer); - const br = editable.children()[2]; - new BoldFormat().applyTo(br); - - mutationNumber = 0; - - await domEngine.redraw(br); - - const domEditable = domEngine.getDomNodes(editable)[0] as Element; - expect(domEditable.innerHTML).to.equal('ab
cd'); - expect(await renderer.render('dom/object', br)).to.deep.equal({ - tag: 'B', - children: [{ tag: 'BR' }], - }); - expect(mutationNumber).to.equal(2, 'remove br, add b'); - - mutationNumber = 0; - - await editor.execCommand('toggleFormat', { - FormatClass: BoldFormat, - }); - - expect(domEditable.innerHTML).to.equal('ab
c
d'); - expect(await renderer.render('dom/object', br)).to.deep.equal({ - tag: 'B', - children: [{ text: 'b' }, { tag: 'BR' }, { text: 'c' }], - }); - expect(mutationNumber).to.equal( - 8, - 'change text, add b, create text, add text, remove br, create text, add text, change text', - ); - }, - contentAfter: 'a[b
c]
d', - }); - }); - it('should render text and linebreak with format', async () => { - await testEditor(BasicEditor, { - contentBefore: 'a[b
c]d', - stepFunction: async (editor: JWEditor) => { - const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const editable = domEngine.components.get('editable')[0]; - - const renderer = editor.plugins.get(Renderer); - const br = editable.children()[2]; - new BoldFormat().applyTo(br); - - mutationNumber = 0; - - await editor.execCommand('toggleFormat', { - FormatClass: BoldFormat, - }); - - const domEditable = domEngine.getDomNodes(editable)[0] as Element; - expect(domEditable.innerHTML).to.equal('ab
c
d'); - expect(await renderer.render('dom/object', br)).to.deep.equal({ - tag: 'B', - children: [{ text: 'b' }, { tag: 'BR' }, { text: 'c' }], - }); - expect(mutationNumber).to.equal( - 8, - 'change text, add b, crete text, add text, move br, create text, add text, change text', - ); - }, - contentAfter: 'a[b
c]
d', - }); - }); - it('should render a linebreak with format between formatted char', async () => { - await testEditor(BasicEditor, { - contentBefore: 'a[b
c]d', - stepFunction: async (editor: JWEditor) => { - const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const editable = domEngine.components.get('editable')[0]; - - const br = editable.children()[2]; - new BoldFormat().applyTo(br); - - mutationNumber = 0; - - await domEngine.redraw(br); - - const domEditable = domEngine.getDomNodes(editable)[0] as Element; - expect(domEditable.innerHTML).to.equal('ab
c
d'); - - const renderer = editor.plugins.get(Renderer); - expect(await renderer.render('dom/object', br)).to.deep.equal({ - tag: 'B', - children: [{ text: 'b' }, { tag: 'BR' }, { text: 'c' }], - }); - expect(mutationNumber).to.equal(5, 'remove second b, move br, move text'); - }, - contentAfter: 'a[b
c]
d', - }); - }); - }); describe('update VNodes and DomNodes with minimum mutations', () => { it('should split a paragraph and keep the created nodes', async () => { const Component: ComponentDefinition = { @@ -3091,6 +3196,8 @@ describe('DomLayout', () => { divDom.appendChild(newPDom); newPDom.appendChild(textDom); + await nextTick(); + // update VNode const layout = editor.plugins.get(Layout); @@ -3098,22 +3205,17 @@ describe('DomLayout', () => { const section = domLayout.components.get('test')[0]; const div = section.firstChild(); const p = div.firstChild(); - const p2 = p.splitAt(p.childVNodes[2]); - // Add an other char to check if the dom are effectively redrawed. - p2.append(new CharNode({ char: 'z' })); - await nextTick(); - mutationNumber = 0; - - // redraw - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const nodesWithChanges = new Set([pDom, textDom, newPDom, newTextDom, divDom]); + const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; engine.markForRedraw(nodesWithChanges); - await engine.redraw( - div, - ...div.childVNodes, - ...flat(div.childVNodes.map(n => n.childVNodes)), - ); + + mutationNumber = 0; + await editor.execCommand(() => { + const p2 = p.splitAt(p.childVNodes[2]); + // Add an other char to check if the dom are effectively redrawed. + p2.append(new CharNode({ char: 'z' })); + }); expect(sectionDom.innerHTML).to.equal('

ab

cdz

'); expect(container.querySelector('section') === sectionDom).to.equal( @@ -3165,6 +3267,8 @@ describe('DomLayout', () => { divDom.appendChild(newPDom); newPDom.appendChild(textDom); + await nextTick(); + // update VNode const layout = editor.plugins.get(Layout); @@ -3172,20 +3276,14 @@ describe('DomLayout', () => { const section = domLayout.components.get('test')[0]; const div = section.firstChild(); const p = div.firstChild(); - p.splitAt(p.childVNodes[2]); - - await nextTick(); - mutationNumber = 0; - - // redraw const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const nodesWithChanges = new Set([pDom, textDom, newPDom, newTextDom, divDom]); engine.markForRedraw(nodesWithChanges); - await engine.redraw( - div, - ...div.childVNodes, - ...flat(div.childVNodes.map(n => n.childVNodes)), - ); + + mutationNumber = 0; + await editor.execCommand(() => { + p.splitAt(p.childVNodes[2]); + }); expect(sectionDom.innerHTML).to.equal('

ab

cd

'); expect(container.querySelector('section') === sectionDom).to.equal( @@ -3238,14 +3336,13 @@ describe('DomLayout', () => { const layout = editor.plugins.get(Layout); const domLayout = layout.engines.dom as DomLayoutEngine; const div = domLayout.components.get('test')[0]; - div.prepend(custom); - await nextTick(); mutationNumber = 0; + await editor.execCommand(() => { + div.prepend(custom); + expect(container.innerHTML).to.equal('
a
'); + }); - expect(container.innerHTML).to.equal('
a
'); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(div, custom); expect(container.innerHTML).to.equal('
a
'); expect(mutationNumber).to.equal(1, 'add '); @@ -3290,7 +3387,7 @@ describe('DomLayout', () => { expect(container.innerHTML).to.equal(''); const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal('
'); expect(mutationNumber).to.equal(1, 'add
'); @@ -3335,7 +3432,7 @@ describe('DomLayout', () => { expect(container.innerHTML).to.equal('
'); const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal(''); expect(mutationNumber).to.equal(1, 'remove
'); @@ -3377,7 +3474,7 @@ describe('DomLayout', () => { expect(container.innerHTML).to.equal('
'); const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal('
'); expect(mutationNumber).to.equal(2, 'remove
, add
'); @@ -3423,7 +3520,7 @@ describe('DomLayout', () => { expect(container.innerHTML).to.equal(''); const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal('
'); expect(mutationNumber).to.equal(1, 'add
'); @@ -3469,7 +3566,7 @@ describe('DomLayout', () => { expect(container.innerHTML).to.equal('
'); const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal(''); expect(mutationNumber).to.equal(1, 'remove
'); @@ -3515,15 +3612,15 @@ describe('DomLayout', () => { expect(container.innerHTML).to.equal('
', '1st'); mutationNumber = 0; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal('', '1st empty'); expect(mutationNumber).to.equal(1, 'remove
'); mutationNumber = 0; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal('
', '2nd'); expect(mutationNumber).to.equal(1, 'add
'); mutationNumber = 0; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal('', '2nd empty'); expect(mutationNumber).to.equal(1, 'remove
'); @@ -3570,7 +3667,7 @@ describe('DomLayout', () => { '
', ); const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.innerHTML).to.equal( '
', ); @@ -3607,13 +3704,15 @@ describe('DomLayout', () => { const b = engine.getNodes(pDom.firstChild)[1]; const area = new VElement({ htmlTag: 'custom' }); - b.after(area); - await nextTick(); mutationNumber = 0; + await editor.execCommand(() => { + b.after(area); + expect(container.innerHTML).to.equal( + '

abc

def

', + ); + }); - expect(container.innerHTML).to.equal('

abc

def

'); - await engine.redraw(area.parent, ...area.parent.childVNodes); expect(container.innerHTML).to.equal( '

ab
c

def

', ); @@ -3647,15 +3746,16 @@ describe('DomLayout', () => { editor.load(Plugin); await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const area = new VElement({ htmlTag: 'custom' }); - p.wrap(area); mutationNumber = 0; - await engine.redraw(area, area.parent, p); + await editor.execCommand(() => { + p.wrap(area); + }); + expect(container.innerHTML).to.equal( '

a

', ); @@ -3689,18 +3789,20 @@ describe('DomLayout', () => { editor.load(Plugin); await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; const pDom = container.querySelector('p'); const text = pDom.firstChild; const area = new VElement({ htmlTag: 'custom' }); - p.wrap(area); - await engine.redraw(area, p.parent, p); - area.unwrap(); + await editor.execCommand(() => { + p.wrap(area); + }); + + await editor.execCommand(() => { + area.unwrap(); + mutationNumber = 0; + }); - mutationNumber = 0; - await engine.redraw(area, p.parent, p); expect(container.innerHTML).to.equal('

a

'); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); @@ -3737,13 +3839,15 @@ describe('DomLayout', () => { const text = pDom.firstChild; const area = new VElement({ htmlTag: 'custom' }); - engine.root - .firstChild() - .firstChild() - .wrap(area); - mutationNumber = 0; - await engine.redraw(area, area.parent, p); + await editor.execCommand(() => { + engine.root + .firstChild() + .firstChild() + .wrap(area); + mutationNumber = 0; + }); + expect(container.innerHTML).to.equal( '

a

', ); @@ -3784,13 +3888,16 @@ describe('DomLayout', () => { const area = new VElement({ htmlTag: 'custom' }); const layoutContainer = engine.root.firstChild(); const layoutchild = layoutContainer.firstChild(); - layoutchild.wrap(area); - await engine.redraw(layoutContainer, area, layoutchild); - area.unwrap(); + await editor.execCommand(() => { + layoutchild.wrap(area); + }); + + await editor.execCommand(() => { + area.unwrap(); + mutationNumber = 0; + }); - mutationNumber = 0; - await engine.redraw(layoutContainer, area, layoutchild); expect(container.innerHTML).to.equal('

a

'); expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); @@ -3857,7 +3964,7 @@ describe('DomLayout', () => { mutationNumber = 0; const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(container.querySelector('section').outerHTML).to.equal( '

abc
' + @@ -3926,7 +4033,12 @@ describe('DomLayout', () => { mutationNumber = 0; const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(customChild); + await engine.redraw({ + add: [], + move: [], + remove: [], + update: [[customChild, ['id']]], + }); expect(container.querySelector('section').outerHTML).to.equal( '
abc
' + @@ -3994,9 +4106,9 @@ describe('DomLayout', () => { await editor.start(); mutationNumber = 0; - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - custom.sectionAttr = 2; - await engine.redraw(custom); + await editor.execCommand(() => { + custom.sectionAttr = 2; + }); expect(container.querySelector('section').outerHTML).to.equal( '
abc
' + @@ -4064,9 +4176,9 @@ describe('DomLayout', () => { await editor.start(); mutationNumber = 0; - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - custom.divAttr = 2; - await engine.redraw(custom); + await editor.execCommand(() => { + custom.divAttr = 2; + }); expect(container.querySelector('section').outerHTML).to.equal( '
abc
' + @@ -4134,9 +4246,9 @@ describe('DomLayout', () => { await editor.start(); mutationNumber = 0; - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - customChild.sectionAttr = 2; - await engine.redraw(customChild); + await editor.execCommand(() => { + customChild.sectionAttr = 2; + }); expect(container.querySelector('section').outerHTML).to.equal( '
abc
' + @@ -4204,9 +4316,9 @@ describe('DomLayout', () => { await editor.start(); mutationNumber = 0; - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - customChild.divAttr = 2; - await engine.redraw(customChild); + await editor.execCommand(() => { + customChild.divAttr = 2; + }); expect(container.querySelector('section').outerHTML).to.equal( '
abc
' + @@ -4216,23 +4328,7 @@ describe('DomLayout', () => { expect(mutationNumber).to.equal(1); - await editor.stop(); - }); - it('should remove some attributes on everything', async () => { - await testEditor(BasicEditor, { - contentBefore: - '

[abcde]

', - stepFunction: async editor => { - await nextTick(); - mutationNumber = 0; - await editor.execCommand('uncolorBackground'); - expect(mutationNumber).to.equal( - 5, - 'remove 3 formats + remove 2 empty styles', - ); - }, - contentAfter: '

[abcde]

', - }); + await editor.stop(); }); it('should not have mutation when redraw a custom fragment with children which have same rendering', async () => { class CustomNode extends AtomicNode {} @@ -4241,7 +4337,10 @@ describe('DomLayout', () => { static id = DomObjectRenderingEngine.id; engine: DomObjectRenderingEngine; predicate = CustomNode; - async render(node: CustomNode): Promise { + async render( + node: CustomNode, + worker: RenderingEngineWorker, + ): Promise { const domObject: DomObjectFragment = { children: [ { @@ -4255,9 +4354,9 @@ describe('DomLayout', () => { }, ], }; - this.engine.locate([node], domObject.children[0] as DomObjectText); - this.engine.locate([node], domObject.children[1] as DomObjectElement); - this.engine.locate([node], domObject.children[2] as DomObjectText); + worker.locate([node], domObject.children[0] as DomObjectText); + worker.locate([node], domObject.children[1] as DomObjectElement); + worker.locate([node], domObject.children[2] as DomObjectText); return domObject; } } @@ -4285,7 +4384,7 @@ describe('DomLayout', () => { // redraw without changes mutationNumber = 0; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(mutationNumber).to.equal(0); await editor.stop(); @@ -4297,7 +4396,10 @@ describe('DomLayout', () => { static id = DomObjectRenderingEngine.id; engine: DomObjectRenderingEngine; predicate = CustomNode; - async render(node: CustomNode): Promise { + async render( + node: CustomNode, + worker: RenderingEngineWorker, + ): Promise { const domObject: DomObjectFragment = { children: [ { @@ -4311,9 +4413,9 @@ describe('DomLayout', () => { }, ], }; - this.engine.locate([node], domObject.children[0] as DomObjectText); - this.engine.locate([node], domObject.children[1] as DomObjectElement); - this.engine.locate([node], domObject.children[2] as DomObjectText); + worker.locate([node], domObject.children[0] as DomObjectText); + worker.locate([node], domObject.children[1] as DomObjectElement); + worker.locate([node], domObject.children[2] as DomObjectText); return domObject; } } @@ -4337,18 +4439,17 @@ describe('DomLayout', () => { await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - - custom.before(new CharNode({ char: 'X' })); - custom.after(new CharNode({ char: 'Y' })); - mutationNumber = 0; - await engine.redraw(custom.parent, custom.previous(), custom.next()); + await editor.execCommand(() => { + custom.before(new CharNode({ char: 'X' })); + custom.after(new CharNode({ char: 'Y' })); + }); expect(mutationNumber).to.equal(2, 'add 2 text'); // redraw without changes mutationNumber = 0; - await engine.redraw(custom); + const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); expect(mutationNumber).to.equal(0); await editor.stop(); @@ -4396,15 +4497,12 @@ describe('DomLayout', () => { editor.load(Plugin); await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - - const a2 = new CharNode({ char: 'a' }); - a.after(a2); - a.remove(); - // redraw with changes but identical result mutationNumber = 0; - await engine.redraw(span, a, a2); + await editor.execCommand(() => { + a.after(new CharNode({ char: 'a' })); + a.remove(); + }); expect(container.innerHTML).to.equal( '
a
', @@ -4456,15 +4554,13 @@ describe('DomLayout', () => { editor.load(Plugin); await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const a2 = new CharNode({ char: 'a' }); - a.after(a2); - a.remove(); - - // redraw with changes but identical result mutationNumber = 0; - await engine.redraw(span, a, a2); + await editor.execCommand(() => { + const a2 = new CharNode({ char: 'a' }); + a.after(a2); + a.remove(); + }); expect(container.innerHTML).to.equal( '
a
', @@ -4518,19 +4614,16 @@ describe('DomLayout', () => { editor.load(Plugin); await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; expect(container.innerHTML).to.equal( '
a
', ); - const a2 = new CharNode({ char: 'a' }); - a.after(a2); - a.remove(); - - // redraw with changes but identical result mutationNumber = 0; - await engine.redraw(span, a, a2); + await editor.execCommand(() => { + a.after(new CharNode({ char: 'a' })); + a.remove(); + }); expect(container.innerHTML).to.equal( '
a
', @@ -4600,25 +4693,26 @@ describe('DomLayout', () => { editor.load(Plugin); await editor.start(); - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; expect(container.innerHTML).to.equal( '

', 'after start', ); - custom.layout = 1; mutationNumber = 0; - await engine.redraw(custom); + await editor.execCommand(() => { + custom.layout = 1; + }); expect(container.innerHTML).to.equal( '
a

b
', 'first change', ); expect(mutationNumber).to.equal(3, 'add {div}, move {article}, remove {section}'); - custom.layout = 0; mutationNumber = 0; - await engine.redraw(custom); + await editor.execCommand(() => { + custom.layout = 0; + }); expect(container.innerHTML).to.equal( '

', 'second change', @@ -4628,9 +4722,10 @@ describe('DomLayout', () => { 'add {section}, move {article}, remove {div, head, content, foot}', ); - custom.layout = 1; mutationNumber = 0; - await engine.redraw(custom); + await editor.execCommand(() => { + custom.layout = 1; + }); expect(container.innerHTML).to.equal( '
a

b
', 'third change', @@ -4642,106 +4737,405 @@ describe('DomLayout', () => { await editor.stop(); }); - describe('style nodes', () => { - let editor: JWEditor; - let div: VNode; - beforeEach(async () => { - editor = new JWEditor(); - editor.load(Html); - editor.load(Char); - editor.configure(DomLayout, { - location: [target, 'replace'], - components: [ - { - id: 'aaa', - async render(editor: JWEditor): Promise { - [div] = await editor.plugins - .get(Parser) - .parse('text/html', '

1

2

3

'); - return [div]; - }, + }); + describe('node rewritten by another node', () => { + enum CustomeType { + 'normal' = 'default', + 'useObject' = 'useObject', + 'changeTag' = 'changeTag', + 'changeTagAndUseObject' = 'changeTagAndUseObject', + 'useAttributes' = 'useAttributes', + 'useAttributesAndUseObject' = 'useAttributesAndUseObject', + } + class CustomNode extends ContainerNode { + parentValue = 0; + } + class CustomChild extends AtomicNode { + value = 0; + constructor(public type: CustomeType) { + super(); + } + } + let custom: CustomNode; + let editor: JWEditor; + beforeEach(() => { + custom = new CustomNode(); + class CustomRenderer extends NodeRenderer { + static id = DomObjectRenderingEngine.id; + engine: DomObjectRenderingEngine; + predicate = CustomNode; + async render( + custom: CustomNode, + worker: RenderingEngineWorker, + ): Promise { + const parent = { + tag: 'PARENT', + children: [], + }; + const children = custom.children() as CustomChild[]; + const domObjects = (await worker.render(children)) as DomObjectElement[]; + for (const index in children) { + const child = children[index]; + const childObject = domObjects[index]; + const sub: DomObjectElement = { + tag: 'WRAP', + children: [], + }; + worker.depends(custom, sub); + worker.depends(sub, custom); + + parent.children.push(sub); + switch (child.type) { + case 'useObject': + sub.children.push(childObject); + // Child is an origin of this parent because the rendering depend of the content. + worker.depends(child, sub); + worker.depends(sub, child); + break; + case 'changeTag': + childObject.tag = 'CHILD-BIS'; + sub.children.push(child); + // Custom is an origin beacuse this rendering change the tag. + worker.depends(custom, childObject); + worker.depends(childObject, custom); + break; + case 'changeTagAndUseObject': + childObject.tag = 'CHILD-BIS'; + sub.children.push(childObject); + // Child is an origin of this parent because the rendering depend of the content. + worker.depends(child, sub); + worker.depends(sub, child); + // Custom is an origin beacuse this rendering change the tag. + worker.depends(custom, childObject); + worker.depends(childObject, custom); + break; + case 'useAttributes': + sub.attributes = childObject.attributes; + delete childObject.attributes; + sub.children.push(child); + // Child is an origin of this parent because the rendering depend of the attributes. + worker.depends(child, sub); + worker.depends(sub, child); + // Custom is an origin beacuse this rendering remove attributes. + worker.depends(childObject, custom); + worker.depends(custom, childObject); + break; + case 'useAttributesAndUseObject': + sub.attributes = childObject.attributes; + delete childObject.attributes; + sub.children.push(childObject); + // Child is an origin of this parent because the rendering depend of the content + attributes. + worker.depends(child, sub); + worker.depends(sub, child); + // Custom is an origin beacuse this rendering remove attributes. + worker.depends(custom, childObject); + worker.depends(childObject, custom); + break; + default: + parent.children.push(child); + break; + } + } + return parent; + } + } + class CustomChildRenderer extends NodeRenderer { + static id = DomObjectRenderingEngine.id; + engine: DomObjectRenderingEngine; + predicate = CustomChild; + async render(child: CustomChild): Promise { + const parent = { + tag: 'CHILD', + attributes: { + 'value': child.value.toString(), }, - ], - componentZones: [['aaa', ['main']]], - }); - await editor.start(); + }; + return parent; + } + } + const Component: ComponentDefinition = { + id: 'test', + async render(): Promise { + return [custom]; + }, + }; + class Plugin extends JWPlugin { + loadables: Loadables = { + components: [Component], + componentZones: [['test', ['main']]], + renderers: [CustomRenderer, CustomChildRenderer], + }; + } + editor = new JWEditor(); + editor.load(Char); + editor.configure(DomLayout, { location: [target, 'replace'] }); + editor.load(Plugin); + }); + afterEach(async () => { + await editor.stop(); + }); + + it('should render node rendering used by his parent', async () => { + const child = new CustomChild(CustomeType.useObject); + custom.append(child); + await editor.start(); + expect(container.innerHTML).to.equal( + '', + ); + await editor.stop(); + }); + it('should redraw node rendering used by his parent when change node value', async () => { + const child = new CustomChild(CustomeType.useObject); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + child.value = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(1, 'update attributes'); + await editor.stop(); + }); + it('should redraw node rendering used by his parent when change parent value', async () => { + const child = new CustomChild(CustomeType.useObject); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + custom.parentValue = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(0, 'update attributes'); + await editor.stop(); + }); + it('should render node rendering tag updated by his parent', async () => { + const child = new CustomChild(CustomeType.changeTag); + custom.append(child); + await editor.start(); + expect(container.innerHTML).to.equal( + '', + ); + await editor.stop(); + }); + it('should redraw node rendering tag updated by his parent when change node value', async () => { + const child = new CustomChild(CustomeType.changeTag); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + child.value = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(1, 'update attributes'); + await editor.stop(); + }); + it('should redraw node rendering tag updated by his parent when change parent value', async () => { + const child = new CustomChild(CustomeType.changeTag); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + custom.parentValue = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(0, 'update attributes'); + await editor.stop(); + }); + it('should render node rendering tag updated and used by his parent', async () => { + const child = new CustomChild(CustomeType.changeTagAndUseObject); + custom.append(child); + await editor.start(); + expect(container.innerHTML).to.equal( + '', + ); + await editor.stop(); + }); + it('should redraw node rendering tag updated and used by his parent when change node value', async () => { + const child = new CustomChild(CustomeType.changeTagAndUseObject); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + child.value = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(1, 'update attributes'); + await editor.stop(); + }); + it('should redraw node rendering tag updated and used by his parent when change parent value', async () => { + const child = new CustomChild(CustomeType.changeTagAndUseObject); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + custom.parentValue = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(0, 'update attributes'); + await editor.stop(); + }); + it('should render node rendering attributes updated by his parent', async () => { + const child = new CustomChild(CustomeType.useAttributes); + custom.append(child); + await editor.start(); + expect(container.innerHTML).to.equal( + '', + ); + await editor.stop(); + }); + it('should redraw node rendering attributes updated by his parent when change node value', async () => { + const child = new CustomChild(CustomeType.useAttributes); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + child.value = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(1, 'update attributes'); + await editor.stop(); + }); + it('should redraw node rendering attributes updated by his parent when change parent value', async () => { + const child = new CustomChild(CustomeType.useAttributes); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + custom.parentValue = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(0, 'update attributes'); + await editor.stop(); + }); + it('should render node rendering attributes updated and node used by his parent', async () => { + const child = new CustomChild(CustomeType.useAttributesAndUseObject); + custom.append(child); + await editor.start(); + expect(container.innerHTML).to.equal( + '', + ); + await editor.stop(); + }); + it('should redraw node rendering attributes updated and node used by his parent when change node value', async () => { + const child = new CustomChild(CustomeType.useAttributesAndUseObject); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + child.value = 1; + }); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(1, 'update attributes'); + await editor.stop(); + }); + it('should redraw node rendering attributes updated and node used by his parent when change parent value', async () => { + const child = new CustomChild(CustomeType.useAttributesAndUseObject); + custom.append(child); + await editor.start(); + mutationNumber = 0; + await editor.execCommand(() => { + custom.parentValue = 1; }); - afterEach(async () => { - await editor.stop(); + expect(container.innerHTML).to.equal( + '', + ); + expect(mutationNumber).to.equal(0, 'update attributes'); + await editor.stop(); + }); + }); + describe('style nodes', () => { + let editor: JWEditor; + let div: VNode; + beforeEach(async () => { + editor = new JWEditor(); + editor.load(Html); + editor.load(Char); + editor.configure(DomLayout, { + location: [target, 'replace'], + components: [ + { + id: 'aaa', + async render(editor: JWEditor): Promise { + [div] = await editor.plugins + .get(Parser) + .parse('text/html', '

1

2

3

'); + return [div]; + }, + }, + ], + componentZones: [['aaa', ['main']]], }); - it('should add a style node', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; + await editor.start(); + }); + afterEach(async () => { + await editor.stop(); + }); + it('should add a style node', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; + mutationNumber = 0; + await editor.execCommand(() => { const attributes = new Attributes(); attributes.set('style', 'color: red;'); div.firstChild().modifiers.prepend(attributes); - - await nextTick(); - mutationNumber = 0; - - await engine.redraw(div.firstChild()); - - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); - - expect(mutationNumber).to.equal(1, 'add style'); }); - it('should add a style node with !important', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; - - const attributes = new Attributes(); - attributes.set('style', 'border: 1px !important;'); - div.firstChild().modifiers.prepend(attributes); - await nextTick(); - mutationNumber = 0; - - await engine.redraw(div.firstChild()); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(mutationNumber).to.equal(1, 'add style'); + }); + it('should remove a style node', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; + const attributes = new Attributes(); + attributes.set('style', 'color: red;'); - expect(mutationNumber).to.equal(1, 'add style'); + await editor.execCommand(() => { + div.firstChild().modifiers.prepend(attributes); }); - it('should remove a style node', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; - const attributes = new Attributes(); - attributes.set('style', 'color: red;'); - div.firstChild().modifiers.prepend(attributes); - await engine.redraw(div.firstChild()); + mutationNumber = 0; + await editor.execCommand(() => { div.firstChild().modifiers.remove(attributes); + }); - await nextTick(); - mutationNumber = 0; - - await engine.redraw(div.firstChild()); - - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(mutationNumber).to.equal(2, 'remove style, remove attribute'); - }); - it('should add two style nodes', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const p2 = container.querySelectorAll('p')[2]; + expect(mutationNumber).to.equal(2, 'remove style, remove attribute'); + }); + it('should add two style nodes', async () => { + const pDom = container.querySelector('p'); + const p2 = container.querySelectorAll('p')[2]; + mutationNumber = 0; + await editor.execCommand(() => { const attributes = new Attributes(); attributes.set('style', 'color: red;'); div.firstChild().modifiers.prepend(attributes); @@ -4749,301 +5143,344 @@ describe('DomLayout', () => { const attributes2 = new Attributes(); attributes2.set('style', 'border: 1px solid black; color: red;'); div.lastChild().modifiers.prepend(attributes2); + }); - await nextTick(); - mutationNumber = 0; - - await engine.redraw(div.firstChild(), div.lastChild()); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(p2).to.equal(container.querySelectorAll('p')[2]); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(p2).to.equal(container.querySelectorAll('p')[2]); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(mutationNumber).to.equal(3, 'add style, add 2 styles'); + }); + it('should update a style node', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; + const p2 = container.querySelectorAll('p')[2]; - expect(mutationNumber).to.equal(3, 'add style, add 2 styles'); - }); - it('should update a style node', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; - const p2 = container.querySelectorAll('p')[2]; + const attributes = new Attributes(); + attributes.set('style', 'color: red;'); - const attributes = new Attributes(); - attributes.set('style', 'color: red;'); + await editor.execCommand(() => { div.firstChild().modifiers.prepend(attributes); const attributes2 = new Attributes(); attributes2.set('style', 'border: 1px solid black; color: red;'); div.lastChild().modifiers.prepend(attributes2); - await engine.redraw(div.firstChild(), div.lastChild()); + }); + mutationNumber = 0; + await editor.execCommand(() => { attributes.set('style', 'color: blue;'); + }); - await nextTick(); - mutationNumber = 0; - - await engine.redraw(div.firstChild()); - - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(p2).to.equal(container.querySelectorAll('p')[2]); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(p2).to.equal(container.querySelectorAll('p')[2]); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(mutationNumber).to.equal(1, 'update style'); - }); - it('should update two style nodes', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const p2 = container.querySelectorAll('p')[2]; + expect(mutationNumber).to.equal(1, 'update style'); + }); + it('should update two style nodes', async () => { + const pDom = container.querySelector('p'); + const p2 = container.querySelectorAll('p')[2]; - const attributes = new Attributes(); - attributes.set('style', 'color: red;'); + const attributes = new Attributes(); + attributes.set('style', 'color: red;'); + const attributes2 = new Attributes(); + attributes2.set('style', 'border: 1px solid black; color: red;'); + + await editor.execCommand(() => { div.firstChild().modifiers.prepend(attributes); - const attributes2 = new Attributes(); - attributes2.set('style', 'border: 1px solid black; color: red;'); div.lastChild().modifiers.prepend(attributes2); - await engine.redraw(div.firstChild(), div.lastChild()); + }); + + mutationNumber = 0; + await editor.execCommand(() => { attributes.set('style', 'color: blue;'); attributes2.set('style', 'border: 2px solid black; color: blue;'); + }); - await nextTick(); - mutationNumber = 0; - - await engine.redraw(div.firstChild(), div.lastChild()); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(p2).to.equal(container.querySelectorAll('p')[2]); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(p2).to.equal(container.querySelectorAll('p')[2]); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(mutationNumber).to.equal(3, 'add style, add 2 styles'); + }); + it('should update a the style but keep custom style from animation', async () => { + const pDom = container.querySelector('p'); + const p2 = container.querySelectorAll('p')[2]; - expect(mutationNumber).to.equal(3, 'add style, add 2 styles'); - }); - it('should update a the style but keep custom style from animation', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const p2 = container.querySelectorAll('p')[2]; + const attributes = new Attributes(); + attributes.set('style', 'color: red;'); + const attributes2 = new Attributes(); + attributes2.set('style', 'border: 1px solid black; color: red;'); - const attributes = new Attributes(); - attributes.set('style', 'color: red;'); + await editor.execCommand(() => { div.firstChild().modifiers.prepend(attributes); - const attributes2 = new Attributes(); - attributes2.set('style', 'border: 1px solid black; color: red;'); div.lastChild().modifiers.prepend(attributes2); - await engine.redraw(div.firstChild(), div.lastChild()); + }); + + container.querySelector('p').style.background = 'black'; + container.querySelectorAll('p')[2].style.background = 'black'; + await nextTick(); + + mutationNumber = 0; + await editor.execCommand(() => { attributes.set('style', 'color: blue;'); attributes2.set('style', 'border: 2px solid black; color: blue;'); - container.querySelector('p').style.background = 'black'; - container.querySelectorAll('p')[2].style.background = 'black'; + }); - await nextTick(); - mutationNumber = 0; + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(p2 === container.querySelectorAll('p')[2]).to.equal( + true, + 'Use the second

', + ); + + expect(mutationNumber).to.equal(3, 'add style, add 2 styles'); + }); + it('should update a the style but do not keep style from the previous element', async () => { + const p = div.firstChild(); - await engine.redraw(div.firstChild(), div.lastChild()); + const attributes = new Attributes(); + attributes.set('style', 'color: red;'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(p2 === container.querySelectorAll('p')[2]).to.equal( - true, - 'Use the second

', - ); + mutationNumber = 0; - expect(mutationNumber).to.equal(3, 'add style, add 2 styles'); + await editor.execCommand(() => { + p.modifiers.prepend(attributes); }); - it('should update a the style but do not keep style from the previous element', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const p = div.firstChild(); + expect(mutationNumber).to.equal(1, 'add a style'); - const attributes = new Attributes(); - attributes.set('style', 'color: red;'); - p.modifiers.prepend(attributes); - await engine.redraw(p); - attributes.set('style', 'color: blue;'); - container.querySelector('p').style.background = 'black'; + container.querySelector('p').style.background = 'black'; + await nextTick(); - await nextTick(); - mutationNumber = 0; + mutationNumber = 0; - await engine.redraw(p); + await editor.execCommand(() => { + attributes.set('style', 'color: blue;'); + }); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(mutationNumber).to.equal(1, 'add a style'); + expect(mutationNumber).to.equal(1, 'update a style'); + mutationNumber = 0; + await editor.execCommand(() => { const p2 = new VElement({ htmlTag: 'P' }); - const attributes2 = new Attributes(); attributes2.set('class', 'aaa'); p2.modifiers.prepend(attributes2); p.remove(); div.prepend(p2); + }); - await nextTick(); - mutationNumber = 0; + expect(container.innerHTML).to.equal( + '


2

3

', + ); - await engine.redraw(p2, div, p); + expect(mutationNumber).to.equal(3, 'remove

, add

, add style'); + }); + it('should add a style node with !important', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; - expect(container.innerHTML).to.equal( - '


2

3

', - ); + mutationNumber = 0; + await editor.execCommand(() => { + const attributes = new Attributes(); + attributes.set('style', 'border: 1px !important;'); + div.firstChild().modifiers.prepend(attributes); + }); + + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); + + expect(mutationNumber).to.equal(1, 'add style'); + }); + it('should remove a style node', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; + const attributes = new Attributes(); + attributes.set('style', 'color: red;'); + + await editor.execCommand(() => { + div.firstChild().modifiers.prepend(attributes); + }); - expect(mutationNumber).to.equal(3, 'remove

, add

, add style'); + mutationNumber = 0; + await editor.execCommand(() => { + div.firstChild().modifiers.remove(attributes); }); + + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); + + expect(mutationNumber).to.equal(2, 'remove style, remove attribute'); + await editor.stop(); }); - describe('className nodes', () => { - let editor: JWEditor; - let div: VNode; - beforeEach(async () => { - editor = new JWEditor(); - editor.load(Html); - editor.load(Char); - editor.configure(DomLayout, { - location: [target, 'replace'], - components: [ - { - id: 'aaa', - async render(editor: JWEditor): Promise { - [div] = await editor.plugins - .get(Parser) - .parse('text/html', '

1

2

3

'); - return [div]; - }, + }); + describe('className nodes', () => { + let editor: JWEditor; + let div: VNode; + beforeEach(async () => { + editor = new JWEditor(); + editor.load(Html); + editor.load(Char); + editor.configure(DomLayout, { + location: [target, 'replace'], + components: [ + { + id: 'aaa', + async render(editor: JWEditor): Promise { + [div] = await editor.plugins + .get(Parser) + .parse('text/html', '

1

2

3

'); + return [div]; }, - ], - componentZones: [['aaa', ['main']]], - }); - await editor.start(); - }); - afterEach(async () => { - await editor.stop(); + }, + ], + componentZones: [['aaa', ['main']]], }); - it('should add classNames', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; + await editor.start(); + }); + afterEach(async () => { + await editor.stop(); + }); + it('should add classNames', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; + mutationNumber = 0; + await editor.execCommand(() => { const attributes = new Attributes(); attributes.set('class', 'a b'); div.firstChild().modifiers.prepend(attributes); + }); - await nextTick(); - mutationNumber = 0; - - await engine.redraw(div.firstChild()); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(mutationNumber).to.equal(2, 'add 2 classNames'); + }); + it('should remove a className', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; - expect(mutationNumber).to.equal(2, 'add 2 classNames'); - }); - it('should remove a className', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; + const attributes = new Attributes(); - const attributes = new Attributes(); + await editor.execCommand(() => { attributes.set('class', 'a b c d'); div.firstChild().modifiers.prepend(attributes); - await engine.redraw(div.firstChild()); - attributes.set('class', 'd a'); + }); - await nextTick(); - mutationNumber = 0; + mutationNumber = 0; + await editor.execCommand(() => { + attributes.set('class', 'd a'); + }); - await engine.redraw(div.firstChild()); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(mutationNumber).to.equal(2, 'remove 2 classNames'); + }); + it('should remove all classNames', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; - expect(mutationNumber).to.equal(2, 'remove 2 classNames'); - }); - it('should remove all classNames', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; + const attributes = new Attributes(); - const attributes = new Attributes(); + await editor.execCommand(() => { attributes.set('class', 'a b c d'); div.firstChild().modifiers.prepend(attributes); - await engine.redraw(div.firstChild()); - div.firstChild().modifiers.remove(attributes); + }); - await nextTick(); - mutationNumber = 0; + mutationNumber = 0; + await editor.execCommand(() => { + div.firstChild().modifiers.remove(attributes); + }); - await engine.redraw(div.firstChild()); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(mutationNumber).to.equal(5, 'remove 4 className, remove attribute'); + }); + it('should remove a className but keep className added by animation', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; - expect(mutationNumber).to.equal(5, 'remove 4 className, remove attribute'); - }); - it('should remove a className but keep className added by animation', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; + const attributes = new Attributes(); - const attributes = new Attributes(); + await editor.execCommand(() => { attributes.set('class', 'a b c d'); div.firstChild().modifiers.prepend(attributes); - await engine.redraw(div.firstChild()); - container.querySelector('p').classList.add('animation'); - attributes.set('class', 'd a'); + }); - await nextTick(); - mutationNumber = 0; + container.querySelector('p').classList.add('animation'); + await nextTick(); - await engine.redraw(div.firstChild()); + mutationNumber = 0; + await editor.execCommand(() => { + attributes.set('class', 'd a'); + }); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(mutationNumber).to.equal(2, 'remove 2 classNames'); - }); - it('should remove all classNames but keep className added by animation', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - const pDom = container.querySelector('p'); - const text = pDom.firstChild; + expect(mutationNumber).to.equal(2, 'remove 2 classNames'); + }); + it('should remove all classNames but keep className added by animation', async () => { + const pDom = container.querySelector('p'); + const text = pDom.firstChild; - const attributes = new Attributes(); + const attributes = new Attributes(); + + await editor.execCommand(() => { attributes.set('class', 'a b c d'); div.firstChild().modifiers.prepend(attributes); - await engine.redraw(div.firstChild()); - container.querySelector('p').classList.add('animation'); - div.firstChild().modifiers.remove(attributes); + }); - await nextTick(); - mutationNumber = 0; + container.querySelector('p').classList.add('animation'); + await nextTick(); - await engine.redraw(div.firstChild()); + mutationNumber = 0; + await editor.execCommand(() => { + div.firstChild().modifiers.remove(attributes); + }); - expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); - expect(pDom.firstChild === text).to.equal(true, 'Use same text'); - expect(container.innerHTML).to.equal( - '

1

2

3

', - ); + expect(container.querySelector('p') === pDom).to.equal(true, 'Use same

'); + expect(pDom.firstChild === text).to.equal(true, 'Use same text'); + expect(container.innerHTML).to.equal( + '

1

2

3

', + ); - expect(mutationNumber).to.equal(4, 'remove 4 classNames'); - }); + expect(mutationNumber).to.equal(4, 'remove 4 classNames'); }); }); }); @@ -5068,10 +5505,12 @@ describe('DomLayout', () => { await editor.start(); }); afterEach(async () => { - await editor.stop(); + await editor?.stop(); }); it('should add a component in a zone', async () => { - await editor.plugins.get(Layout).append('aaa', 'main'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'main'); + }); expect(container.innerHTML).to.equal('
'); await editor.stop(); }); @@ -5096,7 +5535,9 @@ describe('DomLayout', () => { expect(container.innerHTML).to.equal('
'); - await editor.plugins.get(Layout).append('aaa', 'totoZone'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'totoZone'); + }); expect(container.innerHTML).to.equal( '
', @@ -5105,42 +5546,61 @@ describe('DomLayout', () => { await editor.stop(); }); it('should add a component and show it by default', async () => { - await editor.plugins.get(Layout).append('aaa', 'main'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'main'); + }); expect(container.innerHTML).to.equal('
'); }); it('should hide a component', async () => { - await editor.plugins.get(Layout).append('aaa', 'main'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'main'); + }); await editor.execCommand('hide', { componentId: 'aaa' }); expect(container.innerHTML).to.equal(''); }); it('should show a component', async () => { - await editor.plugins.get(Layout).append('aaa', 'main'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'main'); + }); await editor.execCommand('hide', { componentId: 'aaa' }); await editor.execCommand('show', { componentId: 'aaa' }); expect(container.innerHTML).to.equal('
'); }); it('should remove a component', async () => { const layout = editor.plugins.get(Layout); - await layout.append('aaa', 'main'); - await layout.remove('aaa'); + await editor.execCommand(async () => { + await layout.append('aaa', 'main'); + await layout.remove('aaa'); + }); expect(container.innerHTML).to.equal(''); }); it('should remove a component without memory leak', async () => { const layout = editor.plugins.get(Layout); const root = layout.engines.dom.root; const zoneMain = root.descendants(ZoneNode).find(n => n.managedZones.includes('main')); - await layout.append('aaa', 'main'); - const node = zoneMain.children().pop(); - expect(!!zoneMain.hidden.get(node)).to.equal(false, 'Component is visible'); + await editor.execCommand(() => { + return layout.append('aaa', 'main'); + }); + const node = zoneMain.children().slice(-1)[0]; + await editor.execCommand(() => { + zoneMain.children().pop(); + }); + expect(!!zoneMain.hidden?.[node.id]).to.equal(false, 'Component is visible'); await editor.execCommand('hide', { componentId: 'aaa' }); - expect(zoneMain.hidden.get(node)).to.equal(true, 'Component is hidden'); - await layout.remove('aaa'); - expect(zoneMain.hidden.get(node)).to.equal(undefined); + expect(zoneMain.hidden?.[node.id]).to.equal(true, 'Component is hidden'); + await editor.execCommand(() => { + return layout.remove('aaa'); + }); + expect(zoneMain.hidden?.[node.id]).to.equal(undefined); }); it('should remove a component in all layout engines', async () => { - await editor.plugins.get(Layout).append('aaa', 'main'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).append('aaa', 'main'); + }); expect(container.innerHTML).to.equal('
'); - await editor.plugins.get(Layout).remove('aaa'); + await editor.execCommand(() => { + return editor.plugins.get(Layout).remove('aaa'); + }); expect(container.innerHTML).to.equal(''); }); }); @@ -5215,6 +5675,8 @@ describe('DomLayout', () => { await editor.start(); }); afterEach(async () => { + custom = null; + customChild = null; await editor.stop(); }); it('should have the good rendering', async () => { @@ -5260,10 +5722,22 @@ describe('DomLayout', () => { await click(container.querySelector('section section')); expect(flag).to.equal(2); }); + it('should remove listener and cache on close', async () => { + await editor.stop(); + await editor.start(); + await editor.stop(); + await editor.start(); + await editor.execCommand(() => { + custom.sectionAttr = 2; + }); + flag = 0; + await click(container.querySelector('section')); + expect(flag).to.equal(1); + }); it('should keep (detach & attach) listener when redraw without change', async () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(custom); + await engine.redraw({ add: [], move: [], remove: [], update: [[custom, ['id']]] }); flag = 0; await click(container.querySelector('section')); @@ -5274,10 +5748,9 @@ describe('DomLayout', () => { expect(flag).to.equal(2); }); it('should keep (detach & attach) listener when redraw self attribute', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - - custom.sectionAttr = 2; - await engine.redraw(custom); + await editor.execCommand(() => { + custom.sectionAttr = 2; + }); flag = 0; await click(container.querySelector('section')); @@ -5288,10 +5761,9 @@ describe('DomLayout', () => { expect(flag).to.equal(2); }); it('should keep (detach & attach) listener when redraw child attribute', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - - custom.divAttr = 2; - await engine.redraw(custom); + await editor.execCommand(() => { + custom.divAttr = 2; + }); flag = 0; await click(container.querySelector('section')); @@ -5304,7 +5776,7 @@ describe('DomLayout', () => { it('should keep (detach & attach) listener when redraw childNode without change', async () => { const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await engine.redraw(customChild); + await engine.redraw({ add: [], move: [], remove: [], update: [[customChild, ['id']]] }); flag = 0; await click(container.querySelector('section')); @@ -5315,10 +5787,9 @@ describe('DomLayout', () => { expect(flag).to.equal(2); }); it('should keep (detach & attach) listener when redraw childNode self attribute', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - - customChild.sectionAttr = 2; - await engine.redraw(customChild); + await editor.execCommand(() => { + customChild.sectionAttr = 2; + }); flag = 0; await click(container.querySelector('section')); @@ -5329,10 +5800,9 @@ describe('DomLayout', () => { expect(flag).to.equal(2); }); it('should keep (detach & attach) listener when redraw childNode child attribute', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - - customChild.divAttr = 2; - await engine.redraw(customChild); + await editor.execCommand(() => { + customChild.divAttr = 2; + }); flag = 0; await click(container.querySelector('section')); @@ -5343,23 +5813,20 @@ describe('DomLayout', () => { expect(flag).to.equal(2); }); it('should keep (detach & attach) listener when redraw when remove child', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - - customChild.remove(); - await engine.redraw(custom, customChild); + await editor.execCommand(() => { + customChild.remove(); + }); flag = 0; await click(container.querySelector('section')); expect(flag).to.equal(1); }); it('should keep (detach & attach) listener when redraw when add child', async () => { - const engine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - - const child = new CustomNode(); - child.sectionAttr = 2; - custom.append(child); - - await engine.redraw(custom, child); + await editor.execCommand(() => { + const child = new CustomNode(); + child.sectionAttr = 2; + custom.append(child); + }); flag = 0; await click(container.querySelector('section')); diff --git a/packages/plugin-fontawesome/src/FontAwesomeDomObjectRenderer.ts b/packages/plugin-fontawesome/src/FontAwesomeDomObjectRenderer.ts index 7d3d6390c..d5d3cfde7 100644 --- a/packages/plugin-fontawesome/src/FontAwesomeDomObjectRenderer.ts +++ b/packages/plugin-fontawesome/src/FontAwesomeDomObjectRenderer.ts @@ -7,6 +7,7 @@ import { DomObjectText, DomObjectElement, } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; const zeroWidthSpace = '\u200b'; @@ -15,7 +16,10 @@ export class FontAwesomeDomObjectRenderer extends NodeRenderer { engine: DomObjectRenderingEngine; predicate = FontAwesomeNode; - async render(node: FontAwesomeNode): Promise { + async render( + node: FontAwesomeNode, + worker: RenderingEngineWorker, + ): Promise { const fontawesome: DomObjectElement = { tag: node.htmlTag }; // Surround the fontawesome with two invisible characters so the // selection can navigate around it. @@ -45,9 +49,9 @@ export class FontAwesomeDomObjectRenderer extends NodeRenderer { ], }; - this.engine.locate([node], domObject.children[0] as DomObjectText); - this.engine.locate([node], fontawesome); - this.engine.locate([node], domObject.children[2] as DomObjectText); + worker.locate([node], domObject.children[0] as DomObjectText); + worker.locate([node], fontawesome); + worker.locate([node], domObject.children[2] as DomObjectText); return domObject; } diff --git a/packages/plugin-fontawesome/src/FontAwesomeXmlDomParser.ts b/packages/plugin-fontawesome/src/FontAwesomeXmlDomParser.ts index 46508bf40..470d8ebe6 100644 --- a/packages/plugin-fontawesome/src/FontAwesomeXmlDomParser.ts +++ b/packages/plugin-fontawesome/src/FontAwesomeXmlDomParser.ts @@ -15,7 +15,10 @@ export class FontAwesomeXmlDomParser extends AbstractParser { async parse(item: Element): Promise { const fontawesome = new FontAwesomeNode({ htmlTag: nodeName(item) }); - fontawesome.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + fontawesome.modifiers.append(attributes); + } return [fontawesome]; } diff --git a/packages/plugin-fullsreen/src/FullsreenButtonDomObjectRenderer.ts b/packages/plugin-fullsreen/src/FullsreenButtonDomObjectRenderer.ts index dd7bd1f92..3affcd9ad 100644 --- a/packages/plugin-fullsreen/src/FullsreenButtonDomObjectRenderer.ts +++ b/packages/plugin-fullsreen/src/FullsreenButtonDomObjectRenderer.ts @@ -11,6 +11,7 @@ import { Layout } from '../../plugin-layout/src/Layout'; import { Fullscreen } from './Fullscreen'; import { DomLayoutEngine } from '../../plugin-dom-layout/src/DomLayoutEngine'; import { DomObjectActionable } from '../../plugin-dom-layout/src/ActionableDomObjectRenderer'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class FullsreenButtonDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; @@ -21,12 +22,16 @@ export class FullsreenButtonDomObjectRenderer extends NodeRenderer { /** * Render the FullscreenNode. */ - async render(button: ActionableNode): Promise { - const domObject = (await this.super.render(button)) as DomObjectActionable; + async render( + button: ActionableNode, + worker: RenderingEngineWorker, + ): Promise { + const domObject = (await this.super.render(button, worker)) as DomObjectActionable; const fullscreenPlugin = this.engine.editor.plugins.get(Fullscreen); const domLayoutEngine = this.engine.editor.plugins.get(Layout).engines .dom as DomLayoutEngine; + let elButton: Element; domObject.handler = (): void => { // only one component can be display in fullscreen const component = domLayoutEngine.components.get( @@ -39,13 +44,14 @@ export class FullsreenButtonDomObjectRenderer extends NodeRenderer { if (element instanceof Element) { if (fullscreenPlugin.isFullscreen) { element.classList.remove('jw-fullscreen'); + elButton.classList.remove('pressed'); + elButton.setAttribute('aria-pressed', 'false'); } else { fullscreenPlugin.isFullscreen = true; document.body.classList.add('jw-fullscreen'); element.classList.add('jw-fullscreen'); - domLayoutEngine.redraw( - ...domLayoutEngine.components.get('FullscreenButton'), - ); + elButton.classList.add('pressed'); + elButton.setAttribute('aria-pressed', 'true'); window.dispatchEvent(new CustomEvent('resize')); return; } @@ -54,13 +60,13 @@ export class FullsreenButtonDomObjectRenderer extends NodeRenderer { if (fullscreenPlugin.isFullscreen) { fullscreenPlugin.isFullscreen = false; document.body.classList.remove('jw-fullscreen'); - domLayoutEngine.redraw(...domLayoutEngine.components.get('FullscreenButton')); window.dispatchEvent(new CustomEvent('resize')); } }; const attach = domObject.attach; // TODO: Replace these handlers by a `stop` mechanism for renderers. domObject.attach = function(el: Element): void { + elButton = el; attach.call(this, el); if (fullscreenPlugin.isFullscreen) { document.body.classList.add('jw-fullscreen'); @@ -69,6 +75,7 @@ export class FullsreenButtonDomObjectRenderer extends NodeRenderer { const detach = domObject.detach; // TODO: Replace these handlers by a `stop` mechanism for renderers. domObject.detach = function(el: Element): void { + elButton = null; detach.call(this, el); document.body.classList.remove('jw-fullscreen'); }; diff --git a/packages/plugin-heading/src/Heading.ts b/packages/plugin-heading/src/Heading.ts index 0c1b45dd5..fb11b18cc 100644 --- a/packages/plugin-heading/src/Heading.ts +++ b/packages/plugin-heading/src/Heading.ts @@ -79,7 +79,9 @@ export class Heading extends JWPlugin const nodes = editor.selection.range.targetedNodes(); return nodes.every(node => { return node.closest(ancestor => { - return ancestor.is(editor.configuration.defaults.Container); + return ( + ancestor instanceof editor.configuration.defaults.Container + ); }); }); }, diff --git a/packages/plugin-heading/src/HeadingXmlDomParser.ts b/packages/plugin-heading/src/HeadingXmlDomParser.ts index 9f9423ab0..8591b1e91 100644 --- a/packages/plugin-heading/src/HeadingXmlDomParser.ts +++ b/packages/plugin-heading/src/HeadingXmlDomParser.ts @@ -15,7 +15,10 @@ export class HeadingXmlDomParser extends AbstractParser { async parse(item: Element): Promise { const heading = new HeadingNode({ level: parseInt(nodeName(item)[1], 10) }); - heading.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + heading.modifiers.append(attributes); + } const nodes = await this.engine.parse(...item.childNodes); heading.append(...nodes); return [heading]; diff --git a/packages/plugin-heading/test/Heading.test.ts b/packages/plugin-heading/test/Heading.test.ts index 1e5eeb206..957dce349 100644 --- a/packages/plugin-heading/test/Heading.test.ts +++ b/packages/plugin-heading/test/Heading.test.ts @@ -16,7 +16,7 @@ describePlugin(Heading, testEditor => { it('should create a heading', async () => { for (let i = 1; i <= 6; i++) { const vNode = new HeadingNode({ level: i }); - expect(vNode.is(ContainerNode)).to.equal(true); + expect(vNode instanceof ContainerNode).to.equal(true); expect(vNode.htmlTag).to.equal('H' + i); expect(vNode.level).to.equal(i); } diff --git a/packages/plugin-history/src/History.ts b/packages/plugin-history/src/History.ts new file mode 100644 index 000000000..b70e76cf9 --- /dev/null +++ b/packages/plugin-history/src/History.ts @@ -0,0 +1,117 @@ +import { JWPlugin, JWPluginConfig } from '../../core/src/JWPlugin'; +import { JWEditor, Loadables } from '../../core/src/JWEditor'; +import { Keymap } from '../../plugin-keymap/src/Keymap'; +import { Layout } from '../../plugin-layout/src/Layout'; +import { ActionableNode } from '../../plugin-layout/src/ActionableNode'; +import { Attributes } from '../../plugin-xml/src/Attributes'; + +export class History extends JWPlugin { + readonly loadables: Loadables = { + shortcuts: [ + { + pattern: 'CTRL+Z', + commandId: 'undo', + }, + { + pattern: 'CTRL+SHIFT+Z', + commandId: 'redo', + }, + { + pattern: 'CTRL+Y', + commandId: 'redo', + }, + ], + components: [ + { + id: 'UndoButton', + render: async (): Promise => { + const button = new ActionableNode({ + name: 'undo', + label: 'History undo', + commandId: 'undo', + enabled: (): boolean => this._memoryStep > 0, + modifiers: [new Attributes({ class: 'fa fa-undo fa-fw' })], + }); + return [button]; + }, + }, + { + id: 'RedoButton', + render: async (): Promise => { + const button = new ActionableNode({ + name: 'redo', + label: 'History redo', + commandId: 'redo', + enabled: (): boolean => this._memoryKeys.length - 1 > this._memoryStep, + modifiers: [new Attributes({ class: 'fa fa-redo fa-fw' })], + }); + return [button]; + }, + }, + ], + componentZones: [ + ['UndoButton', ['actionables']], + ['RedoButton', ['actionables']], + ], + }; + commands = { + undo: { + handler: this.undo, + }, + redo: { + handler: this.redo, + }, + }; + commandHooks = { + '@commit': this._registerMemoryKey, + }; + + constructor(editor: JWEditor) { + super(editor); + this.loadables.components.push(); + } + + private _memoryKeys: string[] = []; + private _memoryCommands: string[][] = []; + private _memoryStep = -1; + + undo(): void { + this._memoryStep--; + if (this._memoryStep < 0) { + this._memoryStep = 0; + } + this.editor.memory.switchTo(this._memoryKeys[this._memoryStep]); + } + redo(): void { + this._memoryStep++; + const max = this._memoryKeys.length - 1; + if (this._memoryStep > max) { + this._memoryStep = max; + } + this.editor.memory.switchTo(this._memoryKeys[this._memoryStep]); + } + private _registerMemoryKey(): void { + const sliceKey = this.editor.memory.sliceKey; + if (!this._memoryKeys.includes(sliceKey)) { + const commands = this.editor.memoryInfo.commandNames; + if (commands.length === 1 && commands[0] === 'setSelection') { + if (this._memoryStep > this._memoryKeys.length - 1) { + // After an undo, don't replace history for setSelection. + return; + } + const prevCommand = this._memoryCommands[this._memoryStep]; + if (prevCommand && prevCommand.length === 1 && prevCommand[0] === 'setSelection') { + // Concat setSelection. + this._memoryKeys[this._memoryStep] = sliceKey; + return; + } + } + + this._memoryStep++; + this._memoryKeys.splice(this._memoryStep, Infinity, sliceKey); + this._memoryCommands.splice(this._memoryStep, Infinity, [ + ...this.editor.memoryInfo.commandNames, + ]); + } + } +} diff --git a/packages/plugin-html/src/DefaultHtmlDomRenderer.ts b/packages/plugin-html/src/DefaultHtmlDomRenderer.ts index f03acc1c6..f5b247e5f 100644 --- a/packages/plugin-html/src/DefaultHtmlDomRenderer.ts +++ b/packages/plugin-html/src/DefaultHtmlDomRenderer.ts @@ -15,10 +15,10 @@ export class DefaultHtmlDomRenderer extends NodeRenderer { async render(node: VNode): Promise { const renderer = this.engine.editor.plugins.get(Renderer); const objectEngine = renderer.engines['dom/object'] as DomObjectRenderingEngine; - const domObjects = await objectEngine.render([node]); + const cache = await objectEngine.render([node]); const domNodes: Node[] = []; - for (const domObject of domObjects) { - await objectEngine.resolveChildren(domObject); + for (const [, domObject] of cache.renderings) { + await objectEngine.resolveChildren(domObject, cache.worker); const domNode = this._objectToDom(domObject); if (domNode instanceof DocumentFragment) { domNodes.push(...domNode.childNodes); diff --git a/packages/plugin-html/src/HtmlDomRenderingEngine.ts b/packages/plugin-html/src/HtmlDomRenderingEngine.ts index 4ab67eeab..1b7d4332a 100644 --- a/packages/plugin-html/src/HtmlDomRenderingEngine.ts +++ b/packages/plugin-html/src/HtmlDomRenderingEngine.ts @@ -1,30 +1,9 @@ import { RenderingEngine } from '../../plugin-renderer/src/RenderingEngine'; import { DefaultHtmlDomRenderer } from './DefaultHtmlDomRenderer'; import { DefaultHtmlDomModifierRenderer } from './DefaultHtmlDomModifierRenderer'; -import { VNode } from '../../core/src/VNodes/VNode'; -import { Renderer } from '../../plugin-renderer/src/Renderer'; -import { DomObjectRenderingEngine } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; export class HtmlDomRenderingEngine extends RenderingEngine { static id = 'dom/html'; static readonly defaultRenderer = DefaultHtmlDomRenderer; static readonly defaultModifierRenderer = DefaultHtmlDomModifierRenderer; - - /** - * Render the given node. - * - * @param node - */ - async render(node: VNode): Promise; - async render(nodes: VNode[]): Promise; - async render(nodes: VNode | VNode[]): Promise { - const renderer = this.editor.plugins.get(Renderer); - const objectEngine = renderer.engines['dom/object'] as DomObjectRenderingEngine; - objectEngine.clear(); - if (nodes instanceof Array) { - return super.render(nodes); - } else { - return super.render([nodes])[0]; - } - } } diff --git a/packages/plugin-iframe/src/IframeDomObjectRenderer.ts b/packages/plugin-iframe/src/IframeDomObjectRenderer.ts index 79ea8eaae..0f390d2f1 100644 --- a/packages/plugin-iframe/src/IframeDomObjectRenderer.ts +++ b/packages/plugin-iframe/src/IframeDomObjectRenderer.ts @@ -5,7 +5,6 @@ import { import { NodeRenderer } from '../../plugin-renderer/src/NodeRenderer'; import { IframeNode } from './IframeNode'; import { MetadataNode } from '../../plugin-metadata/src/MetadataNode'; -import { Attributes } from '../../plugin-xml/src/Attributes'; export class IframeDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; @@ -21,7 +20,7 @@ export class IframeDomObjectRenderer extends NodeRenderer { tag: 'JW-IFRAME', shadowRoot: true, children: iframeNode.childVNodes.filter( - child => child.tangible || child.is(MetadataNode), + child => child.tangible || child instanceof MetadataNode, ), attach: (wrap: HTMLElement): void => { if (wrap.parentElement instanceof HTMLIFrameElement) { @@ -64,7 +63,6 @@ export class IframeDomObjectRenderer extends NodeRenderer { iframe.removeEventListener('load', onload); }, }; - this.engine.renderAttributes(Attributes, iframeNode, domObject); return domObject; } } diff --git a/packages/plugin-iframe/src/IframeXmlDomParser.ts b/packages/plugin-iframe/src/IframeXmlDomParser.ts index c8ea993e1..8e4f8db5f 100644 --- a/packages/plugin-iframe/src/IframeXmlDomParser.ts +++ b/packages/plugin-iframe/src/IframeXmlDomParser.ts @@ -19,7 +19,10 @@ export class IframeXmlDomParser extends AbstractParser { */ async parse(item: Element): Promise { const shadow = new IframeNode(); - shadow.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + shadow.modifiers.append(attributes); + } const nodes = await this.engine.parse(...item.childNodes); shadow.append(...nodes); return [shadow]; diff --git a/packages/plugin-iframe/test/iframe.test.ts b/packages/plugin-iframe/test/iframe.test.ts index 3c7786f46..2a42b31b0 100644 --- a/packages/plugin-iframe/test/iframe.test.ts +++ b/packages/plugin-iframe/test/iframe.test.ts @@ -57,9 +57,9 @@ describe('Iframe', async () => { await editor.stop(); - expect(node.is(VElement) && node.htmlTag).to.equal('DIV'); + expect(node instanceof VElement && node.htmlTag).to.equal('DIV'); const iframe = node.firstChild(); - expect(iframe.is(IframeNode)).to.equal(true); + expect(iframe instanceof IframeNode).to.equal(true); expect(iframe.firstChild()).to.equal(undefined); }); it('should parse a template with which have content', async () => { @@ -79,8 +79,8 @@ describe('Iframe', async () => { const iframe = node.firstChild(); const section = iframe.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); - expect(section.firstChild().is(CharNode)).to.equal(true); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); + expect(section.firstChild() instanceof CharNode).to.equal(true); }); it('should parse a template with with style tag', async () => { const editor = new JWEditor(); @@ -100,10 +100,12 @@ describe('Iframe', async () => { const iframe = node.firstChild() as IframeNode; const style = iframe.childVNodes[0]; - expect(style.is(MetadataNode) && style.htmlTag).to.equal('STYLE'); - expect(style.is(MetadataNode) && style.contents).to.equal('* { color: red; }'); + expect(style instanceof MetadataNode && style.htmlTag).to.equal('STYLE'); + expect(style instanceof MetadataNode && style.contents).to.equal( + '* { color: red; }', + ); const section = iframe.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); it('should parse a template with with link tag', async () => { const editor = new JWEditor(); @@ -128,23 +130,23 @@ describe('Iframe', async () => { const iframe = node.firstChild() as IframeNode; const link = iframe.childVNodes[0]; - expect(link.is(MetadataNode) && link.htmlTag).to.equal('LINK'); + expect(link instanceof MetadataNode && link.htmlTag).to.equal('LINK'); expect(link.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href1"}', ); const link2 = iframe.childVNodes[1]; - expect(link2.is(MetadataNode) && link2.htmlTag).to.equal('LINK'); + expect(link2 instanceof MetadataNode && link2.htmlTag).to.equal('LINK'); expect(link2.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href2"}', ); const link3 = iframe.childVNodes[2]; - expect(link3.is(MetadataNode) && link3.htmlTag).to.equal('LINK'); + expect(link3 instanceof MetadataNode && link3.htmlTag).to.equal('LINK'); expect(link3.modifiers.find(Attributes).name).to.equal( '{rel: "help", href: "/help/"}', ); const section = iframe.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); it('should parse a template with with style and link tag', async () => { const editor = new JWEditor(); @@ -169,20 +171,22 @@ describe('Iframe', async () => { const iframe = node.firstChild() as IframeNode; const link = iframe.childVNodes[0]; - expect(link.is(MetadataNode) && link.htmlTag).to.equal('LINK'); + expect(link instanceof MetadataNode && link.htmlTag).to.equal('LINK'); expect(link.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href1"}', ); const style = iframe.childVNodes[1]; - expect(style.is(MetadataNode) && style.htmlTag).to.equal('STYLE'); - expect(style.is(MetadataNode) && style.contents).to.equal('* { color: red; }'); + expect(style instanceof MetadataNode && style.htmlTag).to.equal('STYLE'); + expect(style instanceof MetadataNode && style.contents).to.equal( + '* { color: red; }', + ); const link2 = iframe.childVNodes[2]; - expect(link2.is(MetadataNode) && link2.htmlTag).to.equal('LINK'); + expect(link2 instanceof MetadataNode && link2.htmlTag).to.equal('LINK'); expect(link2.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href2"}', ); const section = iframe.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); }); describe('parse dom/html', async () => { @@ -202,9 +206,9 @@ describe('Iframe', async () => { await editor.stop(); - expect(node.is(VElement) && node.htmlTag).to.equal('DIV'); + expect(node instanceof VElement && node.htmlTag).to.equal('DIV'); const iframe = node.firstChild(); - expect(iframe.is(IframeNode)).to.equal(true); + expect(iframe instanceof IframeNode).to.equal(true); expect(iframe.firstChild()).to.equal(undefined); }); it('should parse a HtmlDocument with iframe container', async () => { @@ -225,9 +229,9 @@ describe('Iframe', async () => { await editor.stop(); - expect(node.is(VElement) && node.htmlTag).to.equal('DIV'); + expect(node instanceof VElement && node.htmlTag).to.equal('DIV'); const iframe = node.firstChild(); - expect(iframe.is(IframeNode)).to.equal(true); + expect(iframe instanceof IframeNode).to.equal(true); expect(iframe.firstChild()).to.equal(undefined); }); it('should parse a HtmlDocument with iframe which have content', async () => { @@ -254,10 +258,10 @@ describe('Iframe', async () => { await editor.stop(); const iframe = node.firstChild(); - expect(iframe.is(IframeNode)).to.equal(true); + expect(iframe instanceof IframeNode).to.equal(true); const section = iframe.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); - expect(section.firstChild().is(CharNode)).to.equal(true); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); + expect(section.firstChild() instanceof CharNode).to.equal(true); }); it('should parse a HtmlDocument with iframe container with style tag', async () => { const editor = new JWEditor(); @@ -287,10 +291,12 @@ describe('Iframe', async () => { const iframe = node.firstChild() as IframeNode; const style = iframe.childVNodes[0]; - expect(style.is(MetadataNode) && style.htmlTag).to.equal('STYLE'); - expect(style.is(MetadataNode) && style.contents).to.equal('* { color: red; }'); + expect(style instanceof MetadataNode && style.htmlTag).to.equal('STYLE'); + expect(style instanceof MetadataNode && style.contents).to.equal( + '* { color: red; }', + ); const section = iframe.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); it('should parse a HtmlDocument with iframe container with link tag', async () => { const editor = new JWEditor(); @@ -329,22 +335,22 @@ describe('Iframe', async () => { const iframe = node.firstChild() as IframeNode; const link = iframe.childVNodes[0]; - expect(link.is(MetadataNode) && link.htmlTag).to.equal('LINK'); + expect(link instanceof MetadataNode && link.htmlTag).to.equal('LINK'); expect(link.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href1"}', ); const link2 = iframe.childVNodes[1]; - expect(link2.is(MetadataNode) && link2.htmlTag).to.equal('LINK'); + expect(link2 instanceof MetadataNode && link2.htmlTag).to.equal('LINK'); expect(link2.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href2"}', ); const link3 = iframe.childVNodes[2]; - expect(link3.is(MetadataNode) && link3.htmlTag).to.equal('LINK'); + expect(link3 instanceof MetadataNode && link3.htmlTag).to.equal('LINK'); expect(link3.modifiers.find(Attributes).name).to.equal( '{rel: "help", href: "/help/"}', ); const section = iframe.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); it('should parse a HtmlDocument with iframe container with style and link tag', async () => { const editor = new JWEditor(); @@ -382,20 +388,22 @@ describe('Iframe', async () => { const iframe = node.firstChild() as IframeNode; const link = iframe.childVNodes[0]; - expect(link.is(MetadataNode) && link.htmlTag).to.equal('LINK'); + expect(link instanceof MetadataNode && link.htmlTag).to.equal('LINK'); expect(link.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href1"}', ); const style = iframe.childVNodes[1]; - expect(style.is(MetadataNode) && style.htmlTag).to.equal('STYLE'); - expect(style.is(MetadataNode) && style.contents).to.equal('* { color: red; }'); + expect(style instanceof MetadataNode && style.htmlTag).to.equal('STYLE'); + expect(style instanceof MetadataNode && style.contents).to.equal( + '* { color: red; }', + ); const link2 = iframe.childVNodes[2]; - expect(link2.is(MetadataNode) && link2.htmlTag).to.equal('LINK'); + expect(link2 instanceof MetadataNode && link2.htmlTag).to.equal('LINK'); expect(link2.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href2"}', ); const section = iframe.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); }); }); diff --git a/packages/plugin-image/src/ImageDomObjectRenderer.ts b/packages/plugin-image/src/ImageDomObjectRenderer.ts index 22604e227..ee5529d3c 100644 --- a/packages/plugin-image/src/ImageDomObjectRenderer.ts +++ b/packages/plugin-image/src/ImageDomObjectRenderer.ts @@ -4,16 +4,17 @@ import { DomObjectRenderingEngine, DomObject, } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class ImageDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; engine: DomObjectRenderingEngine; predicate = ImageNode; - async render(node: ImageNode): Promise { + async render(node: ImageNode, worker: RenderingEngineWorker): Promise { const select = (): void => { this.engine.editor.nextEventMutex(() => { - return this.engine.editor.execCustomCommand(async () => { + return this.engine.editor.execCommand(async () => { this.engine.editor.selection.select(node, node); }); }); @@ -33,7 +34,7 @@ export class ImageDomObjectRenderer extends NodeRenderer { if (isSelected) { image.attributes = { class: new Set(['jw_selected_image']) }; } - this.engine.locate([node], image); + worker.locate([node], image); return image; } } diff --git a/packages/plugin-indent/src/Indent.ts b/packages/plugin-indent/src/Indent.ts index 52bb5b3e1..296996dec 100644 --- a/packages/plugin-indent/src/Indent.ts +++ b/packages/plugin-indent/src/Indent.ts @@ -6,7 +6,7 @@ import { Char } from '../../plugin-char/src/Char'; import { CharNode } from '../../plugin-char/src/CharNode'; import { LineBreak } from '../../plugin-linebreak/src/LineBreak'; import { LineBreakNode } from '../../plugin-linebreak/src/LineBreakNode'; -import { withRange, VRange } from '../../core/src/VRange'; +import { VRange } from '../../core/src/VRange'; import { Keymap } from '../../plugin-keymap/src/Keymap'; import { ContainerNode } from '../../core/src/VNodes/ContainerNode'; import { AtomicNode } from '../../core/src/VNodes/AtomicNode'; @@ -106,8 +106,8 @@ export class Indent extends JWPlugin< for (const segmentBreak of segmentBreaks) { // Insert 4 spaces at the start of next segment. const [node, position] = this._nextSegmentStart(segmentBreak); - await withRange(this.editor, VRange.at(node, position), async range => { - await this.editor.execCommand('insertText', { + await this.editor.withRange(VRange.at(node, position), async range => { + return this.editor.execCommand('insertText', { text: this.tab, context: { range: range, @@ -157,7 +157,7 @@ export class Indent extends JWPlugin< * @param params */ _isSegmentBreak(node: VNode): boolean { - return node.is(ContainerNode) || node.is(LineBreakNode); + return node instanceof ContainerNode || node instanceof LineBreakNode; } /** * Return the next segment start point after the given segment break. @@ -167,7 +167,7 @@ export class Indent extends JWPlugin< _nextSegmentStart(segmentBreak: VNode): Point { let reference = segmentBreak; let position = RelativePosition.BEFORE; - if (segmentBreak.is(AtomicNode)) { + if (segmentBreak instanceof AtomicNode) { reference = segmentBreak.nextSibling(); } else if (segmentBreak.hasChildren()) { reference = segmentBreak.firstChild(); @@ -182,7 +182,7 @@ export class Indent extends JWPlugin< * @param node */ _isSpace(node: VNode): boolean { - return node.is(CharNode) && /^\s$/g.test(node.char); + return node instanceof CharNode && /^\s$/g.test(node.char); } /** * Return true if the given VNode can be considered to be a segment break. @@ -191,7 +191,7 @@ export class Indent extends JWPlugin< */ _nextIndentationSpace(segmentBreak: VNode): VNode { let space: VNode; - if (segmentBreak.is(AtomicNode)) { + if (segmentBreak instanceof AtomicNode) { space = segmentBreak.nextSibling(); } else { space = segmentBreak.firstChild(); diff --git a/packages/plugin-indent/test/Indent.test.ts b/packages/plugin-indent/test/Indent.test.ts index 0ce392862..ef65ac4ec 100644 --- a/packages/plugin-indent/test/Indent.test.ts +++ b/packages/plugin-indent/test/Indent.test.ts @@ -1,7 +1,7 @@ import { describePlugin, unformat } from '../../utils/src/testUtils'; import JWEditor from '../../core/src/JWEditor'; import { Indent } from '../src/Indent'; -import { withRange, VRange } from '../../core/src/VRange'; +import { VRange } from '../../core/src/VRange'; import { BasicEditor } from '../../bundle-basic-editor/BasicEditor'; import { Layout } from '../../plugin-layout/src/Layout'; @@ -950,7 +950,7 @@ describePlugin(Indent, testEditor => { const editable = domEngine.components.get('editable')[0]; const bNode = editable.next(node => node.name === 'b'); const dNode = editable.next(node => node.name === 'd'); - await withRange(editor, VRange.selecting(bNode, dNode), async range => { + await editor.withRange(VRange.selecting(bNode, dNode), async range => { await editor.execCommand('indent', { context: { range: range, @@ -1034,7 +1034,7 @@ describePlugin(Indent, testEditor => { const editable = domEngine.components.get('editable')[0]; const bNode = editable.next(node => node.name === 'b'); const dNode = editable.next(node => node.name === 'd'); - await withRange(editor, VRange.selecting(bNode, dNode), async range => { + await editor.withRange(VRange.selecting(bNode, dNode), async range => { await editor.execCommand('outdent', { context: { range: range, diff --git a/packages/plugin-inline/src/FormatXmlDomParser.ts b/packages/plugin-inline/src/FormatXmlDomParser.ts index 1195c2a8e..670da853f 100644 --- a/packages/plugin-inline/src/FormatXmlDomParser.ts +++ b/packages/plugin-inline/src/FormatXmlDomParser.ts @@ -14,7 +14,7 @@ export abstract class FormatXmlDomParser extends AbstractParser { */ applyFormat(format: Format, nodes: VNode[]): void { for (const node of nodes) { - if (node.is(InlineNode)) { + if (node instanceof InlineNode) { format.clone().applyTo(node); } else { const inlineNodes = node.descendants(InlineNode); diff --git a/packages/plugin-inline/src/Inline.ts b/packages/plugin-inline/src/Inline.ts index c603c44fa..c4127e861 100644 --- a/packages/plugin-inline/src/Inline.ts +++ b/packages/plugin-inline/src/Inline.ts @@ -13,6 +13,8 @@ import { Loadables } from '../../core/src/JWEditor'; import { Renderer } from '../../plugin-renderer/src/Renderer'; import { Layout } from '../../plugin-layout/src/Layout'; import { ActionableNode } from '../../plugin-layout/src/ActionableNode'; +import { makeVersionable } from '../../core/src/Memory/Versionable'; +import { VRange } from '../../core/src/VRange'; export interface FormatParams extends CommandParams { FormatClass: Constructor; @@ -60,10 +62,17 @@ export class Inline extends JWPlugin< * the modifier in the following property. This value is reset each time the * range changes in a document. */ - cache: InlineCache = { - modifiers: null, - style: null, - }; + cache: InlineCache = makeVersionable({ + modifiers: undefined, + style: undefined, + }); + + start(): Promise { + this.editor.memory.attach(this.cache); + this.getCurrentModifiers(); + this.getCurrentStyle(); + return super.start(); + } //-------------------------------------------------------------------------- // Public @@ -125,7 +134,7 @@ export class Inline extends JWPlugin< isAllFormat(FormatClass: Constructor, range = this.editor.selection.range): boolean { if (range.isCollapsed()) { if (!this.cache.modifiers) { - return !!this.getCurrentModifiers(range).find(FormatClass); + return !!this.getCurrentModifiers(range)?.find(FormatClass); } return !!this.cache.modifiers.find(FormatClass); } else { @@ -139,53 +148,61 @@ export class Inline extends JWPlugin< /** * Get the modifiers for the next insertion. */ - getCurrentModifiers(range = this.editor.selection.range): Modifiers { - if (this.cache.modifiers) { - return this.cache.modifiers; - } - - let inlineToCopyModifiers: VNode; - if (range.isCollapsed()) { - // TODO: LineBreakNode should have the formats as well. - inlineToCopyModifiers = - range.start.previousSibling(node => !node.test(LineBreakNode)) || - range.start.nextSibling(); + getCurrentModifiers(range?: VRange): Modifiers | null { + const storeInCache = !range; + range = range || this.editor.selection.range; + if (this.cache.modifiers === undefined || range !== this.editor.selection.range) { + let inlineToCopyModifiers: VNode; + if (range.isCollapsed()) { + // TODO: LineBreakNode should have the formats as well. + inlineToCopyModifiers = + range.start.previousSibling(node => !(node instanceof LineBreakNode)) || + range.start.nextSibling(); + } else { + inlineToCopyModifiers = range.start.nextSibling(); + } + let modifiers: Modifiers = null; + if (inlineToCopyModifiers && inlineToCopyModifiers instanceof InlineNode) { + modifiers = inlineToCopyModifiers.modifiers.clone(); + } + if (storeInCache) { + this.cache.modifiers = modifiers; + } + return modifiers; } else { - inlineToCopyModifiers = range.start.nextSibling(); - } - if (inlineToCopyModifiers && inlineToCopyModifiers.is(InlineNode)) { - return inlineToCopyModifiers.modifiers.clone(); + return this.cache.modifiers; } - - return new Modifiers(); } /** * Get the styles for the next insertion. */ - getCurrentStyle(range = this.editor.selection.range): CssStyle { - if (this.cache.style) { - return this.cache.style; - } - - let inlineToCopyStyle: VNode; - if (range.isCollapsed()) { - inlineToCopyStyle = range.start.previousSibling() || range.start.nextSibling(); - } else { - inlineToCopyStyle = range.start.nextSibling(); - } - if (inlineToCopyStyle && inlineToCopyStyle.is(InlineNode)) { - return inlineToCopyStyle.modifiers.find(Attributes)?.style.clone() || new CssStyle(); + getCurrentStyle(range?: VRange): CssStyle | null { + const storeInCache = !range; + range = range || this.editor.selection.range; + if (this.cache.style === undefined || range !== this.editor.selection.range) { + let inlineToCopyStyle: VNode; + if (range.isCollapsed()) { + inlineToCopyStyle = range.start.previousSibling() || range.start.nextSibling(); + } else { + inlineToCopyStyle = range.start.nextSibling(); + } + let style: CssStyle = null; + if (inlineToCopyStyle && inlineToCopyStyle instanceof InlineNode) { + style = inlineToCopyStyle.modifiers.find(Attributes)?.style.clone() || null; + } + if (storeInCache) { + this.cache.style = style; + } + return style; } - return new CssStyle(); + return this.cache.style; } /** * Each time the selection changes, we reset its format and style. */ resetCache(): void { - this.cache = { - modifiers: null, - style: null, - }; + this.cache.modifiers = undefined; + this.cache.style = undefined; } /** * Remove the formatting of the nodes in the range. diff --git a/packages/plugin-input/src/InputXmlDomParser.ts b/packages/plugin-input/src/InputXmlDomParser.ts index ae6dc0e1b..560337003 100644 --- a/packages/plugin-input/src/InputXmlDomParser.ts +++ b/packages/plugin-input/src/InputXmlDomParser.ts @@ -18,12 +18,14 @@ export class InputXmlDomParser extends AbstractParser { name: item.getAttribute('name'), value: item.value, }); - input.modifiers.append(this.engine.parseAttributes(item)); - const attributes = input.modifiers.find(Attributes); + const attributes = this.engine.parseAttributes(item); if (attributes) { attributes.remove('type'); // type is on input.inputType attributes.remove('name'); // type is on input.inputName } + if (attributes.length) { + input.modifiers.append(attributes); + } const nodes = await this.engine.parse(...item.childNodes); input.append(...nodes); return [input]; diff --git a/packages/plugin-italic/src/ItalicXmlDomParser.ts b/packages/plugin-italic/src/ItalicXmlDomParser.ts index ad6ad9a6a..911385765 100644 --- a/packages/plugin-italic/src/ItalicXmlDomParser.ts +++ b/packages/plugin-italic/src/ItalicXmlDomParser.ts @@ -15,7 +15,10 @@ export class ItalicXmlDomParser extends FormatXmlDomParser { */ async parse(item: Element): Promise { const italic = new ItalicFormat(nodeName(item) as 'I' | 'EM'); - italic.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + italic.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); this.applyFormat(italic, children); diff --git a/packages/plugin-layout/src/ActionableNode.ts b/packages/plugin-layout/src/ActionableNode.ts index c90b2ba3c..1f53991e6 100644 --- a/packages/plugin-layout/src/ActionableNode.ts +++ b/packages/plugin-layout/src/ActionableNode.ts @@ -2,6 +2,7 @@ import { AtomicNode } from '../../core/src/VNodes/AtomicNode'; import { CommandParams } from '../../core/src/Dispatcher'; import JWEditor from '../../core/src/JWEditor'; import { AbstractNodeParams } from '../../core/src/VNodes/AbstractNode'; +import { makeVersionable } from '../../core/src/Memory/Versionable'; interface ActionableNodeParams extends AbstractNodeParams { name: string; @@ -23,7 +24,7 @@ export class ActionableNode extends AtomicNode { this.actionName = params.name; this.label = params.label; this.commandId = params.commandId; - this.commandArgs = params.commandArgs; + this.commandArgs = params.commandArgs && makeVersionable(params.commandArgs); if (params.selected) { this.selected = params.selected; } diff --git a/packages/plugin-layout/src/LayoutEngine.ts b/packages/plugin-layout/src/LayoutEngine.ts index 5a31ee27f..05d0311ca 100644 --- a/packages/plugin-layout/src/LayoutEngine.ts +++ b/packages/plugin-layout/src/LayoutEngine.ts @@ -36,6 +36,7 @@ export abstract class LayoutEngine { // Add into the default zone if no valid zone could be found. throw new Error('Please define a "default" zone in your template.'); } + this.editor.memory.attach(this.root); } /** * Hide all components. diff --git a/packages/plugin-layout/src/ZoneNode.ts b/packages/plugin-layout/src/ZoneNode.ts index 97e5f68f2..cc344ef8b 100644 --- a/packages/plugin-layout/src/ZoneNode.ts +++ b/packages/plugin-layout/src/ZoneNode.ts @@ -1,6 +1,7 @@ import { ContainerNode } from '../../core/src/VNodes/ContainerNode'; import { VNode } from '../../core/src/VNodes/VNode'; import { AbstractNodeParams } from '../../core/src/VNodes/AbstractNode'; +import { makeVersionable } from '../../core/src/Memory/Versionable'; export type ZoneIdentifier = string; export interface ZoneNodeParams extends AbstractNodeParams { @@ -8,14 +9,14 @@ export interface ZoneNodeParams extends AbstractNodeParams { } export class ZoneNode extends ContainerNode { - hidden: Map = new Map(); + hidden: Record; editable = false; breakable = false; managedZones: ZoneIdentifier[]; constructor(params: ZoneNodeParams) { super(params); - this.managedZones = params.managedZones; + this.managedZones = makeVersionable(params.managedZones); } get name(): string { @@ -23,11 +24,17 @@ export class ZoneNode extends ContainerNode { } hide(child: VNode): void { - this.hidden.set(child, true); + if (!this.hidden) { + this.hidden = makeVersionable({}); + } + this.hidden[child.id] = true; return; } show(child: VNode): void { - this.hidden.set(child, false); + const id = child.id; + if (this.hidden?.[id]) { + this.hidden[id] = false; + } const parentZone: ZoneNode = this.ancestor(ZoneNode); if (parentZone) { parentZone.show(this); @@ -36,6 +43,8 @@ export class ZoneNode extends ContainerNode { _removeAtIndex(index: number): void { const child = this.childVNodes[index]; super._removeAtIndex(index); - this.hidden.delete(child); + if (this.hidden) { + delete this.hidden[child.id]; + } } } diff --git a/packages/plugin-linebreak/src/LineBreakDomObjectRenderer.ts b/packages/plugin-linebreak/src/LineBreakDomObjectRenderer.ts index f04de9546..12f57257b 100644 --- a/packages/plugin-linebreak/src/LineBreakDomObjectRenderer.ts +++ b/packages/plugin-linebreak/src/LineBreakDomObjectRenderer.ts @@ -4,6 +4,7 @@ import { DomObjectRenderingEngine, DomObject, } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class LineBreakDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; @@ -13,15 +14,18 @@ export class LineBreakDomObjectRenderer extends NodeRenderer { /** * Render the VNode to the given format. */ - async render(node: LineBreakNode): Promise { + async render( + node: LineBreakNode, + worker: RenderingEngineWorker, + ): Promise { const br: DomObject = { tag: 'BR' }; - this.engine.locate([node], br); + worker.locate([node], br); if (!node.nextSibling()) { // If a LineBreakNode has no next sibling, it must be rendered // as two BRs in order for it to be visible. const br2 = { tag: 'BR' }; const domObject = { children: [br, br2] }; - this.engine.locate([node], br2); + worker.locate([node], br2); return domObject; } return br; diff --git a/packages/plugin-linebreak/src/LineBreakXmlDomParser.ts b/packages/plugin-linebreak/src/LineBreakXmlDomParser.ts index a6f0c1765..ca706d7ce 100644 --- a/packages/plugin-linebreak/src/LineBreakXmlDomParser.ts +++ b/packages/plugin-linebreak/src/LineBreakXmlDomParser.ts @@ -17,7 +17,10 @@ export class LineBreakXmlDomParser extends AbstractParser { return []; } const lineBreak = new LineBreakNode(); - lineBreak.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + lineBreak.modifiers.append(attributes); + } return [lineBreak]; } diff --git a/packages/plugin-linebreak/test/LineBreak.test.ts b/packages/plugin-linebreak/test/LineBreak.test.ts index 736538e94..e3526429b 100644 --- a/packages/plugin-linebreak/test/LineBreak.test.ts +++ b/packages/plugin-linebreak/test/LineBreak.test.ts @@ -47,7 +47,7 @@ describePlugin(LineBreak, testEditor => { await new LineBreakXmlDomParser(engine).parse(br1) )[0] as LineBreakNode; expect(lineBreak instanceof AbstractNode).to.be.true; - expect(lineBreak.is(AtomicNode)).to.equal(true); + expect(lineBreak instanceof AtomicNode).to.equal(true); }); it('should not parse a SPAN node', async () => { const engine = new XmlDomParsingEngine(new JWEditor()); @@ -91,7 +91,7 @@ describePlugin(LineBreak, testEditor => { describe('constructor', () => { it('should create a LineBreakNode', async () => { const lineBreak = new LineBreakNode(); - expect(lineBreak.is(AtomicNode)).to.equal(true); + expect(lineBreak instanceof AtomicNode).to.equal(true); }); }); describe('clone', () => { diff --git a/packages/plugin-link/src/Link.ts b/packages/plugin-link/src/Link.ts index e1278e419..2f1b2da75 100644 --- a/packages/plugin-link/src/Link.ts +++ b/packages/plugin-link/src/Link.ts @@ -36,7 +36,7 @@ export class Link extends JWPlugin if (link instanceof AbstractNode) { node = link; } - const format = node.is(InlineNode) && node.modifiers.find(LinkFormat); + const format = node instanceof InlineNode && node.modifiers.find(LinkFormat); return link instanceof AbstractNode ? !!format : link.isSameAs(format); } static dependencies = [Inline]; @@ -79,7 +79,11 @@ export class Link extends JWPlugin selected: (editor: JWEditor): boolean => { const range = editor.selection.range; const node = range.start.nextSibling() || range.start.previousSibling(); - return node && node.is(InlineNode) && !!node.modifiers.find(LinkFormat); + return ( + node && + node instanceof InlineNode && + !!node.modifiers.find(LinkFormat) + ); }, modifiers: [new Attributes({ class: 'fa fa-link fa-fw' })], }); @@ -96,7 +100,11 @@ export class Link extends JWPlugin enabled: (editor: JWEditor): boolean => { const range = editor.selection.range; const node = range.start.nextSibling() || range.start.previousSibling(); - return node && node.is(InlineNode) && !!node.modifiers.find(LinkFormat); + return ( + node && + node instanceof InlineNode && + !!node.modifiers.find(LinkFormat) + ); }, modifiers: [new Attributes({ class: 'fa fa-unlink fa-fw' })], }); diff --git a/packages/plugin-link/src/LinkFormat.ts b/packages/plugin-link/src/LinkFormat.ts index 073b57f7c..483108009 100644 --- a/packages/plugin-link/src/LinkFormat.ts +++ b/packages/plugin-link/src/LinkFormat.ts @@ -22,11 +22,13 @@ export class LinkFormat extends Format { } get target(): string { - return this.modifiers.find(Attributes)?.get('target'); + return this.modifiers.find(Attributes)?.get('target') || ''; } - set target(url: string) { - this.modifiers.get(Attributes).set('target', url); + set target(target: string) { + if (target.length) { + this.modifiers.get(Attributes).set('target', target); + } } //-------------------------------------------------------------------------- diff --git a/packages/plugin-link/src/LinkXmlDomParser.ts b/packages/plugin-link/src/LinkXmlDomParser.ts index d8b5718c4..9399645be 100644 --- a/packages/plugin-link/src/LinkXmlDomParser.ts +++ b/packages/plugin-link/src/LinkXmlDomParser.ts @@ -13,7 +13,10 @@ export class LinkXmlDomParser extends FormatXmlDomParser { const link = new LinkFormat(item.getAttribute('href')); // TODO: Link should not have an `Attributes` modifier outside of XML. // In XML context we need to conserve the order of attributes. - link.modifiers.replace(Attributes, this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + link.modifiers.replace(Attributes, attributes); + } const children = await this.engine.parse(...item.childNodes); this.applyFormat(link, children); diff --git a/packages/plugin-link/src/components/LinkComponent.ts b/packages/plugin-link/src/components/LinkComponent.ts index ee325e9ea..74b0ce6b8 100644 --- a/packages/plugin-link/src/components/LinkComponent.ts +++ b/packages/plugin-link/src/components/LinkComponent.ts @@ -1,6 +1,4 @@ import { OwlComponent } from '../../../plugin-owl/src/OwlComponent'; -import { InlineNode } from '../../../plugin-inline/src/InlineNode'; -import { LinkFormat } from '../LinkFormat'; import { LinkParams } from '../Link'; import { Layout } from '../../../plugin-layout/src/Layout'; import { useState } from '@odoo/owl'; diff --git a/packages/plugin-list/src/List.ts b/packages/plugin-list/src/List.ts index 5198c1898..0799d4382 100644 --- a/packages/plugin-list/src/List.ts +++ b/packages/plugin-list/src/List.ts @@ -6,7 +6,7 @@ import { ListDomObjectRenderer } from './ListDomObjectRenderer'; import { ListItemAttributesDomObjectModifierRenderer } from './ListItemAttributesDomObjectModifierRenderer'; import { ListXmlDomParser } from './ListXmlDomParser'; import { ListItemXmlDomParser, ListItemAttributes } from './ListItemXmlDomParser'; -import { withRange, VRange } from '../../core/src/VRange'; +import { VRange } from '../../core/src/VRange'; import { IndentParams, OutdentParams } from '../../plugin-indent/src/Indent'; import { CheckingContext } from '../../core/src/ContextManager'; import { InsertParagraphBreakParams } from '../../core/src/Core'; @@ -176,92 +176,93 @@ export class List extends JWPlugin * * @param params */ - toggleList(params: ListParams): Promise { + toggleList(params: ListParams): void { const type = params.type; - const range = params.context.range; - - return withRange(this.editor, VRange.clone(range), range => { - // Extend the range to cover the entirety of its containers. - if (range.startContainer.hasChildren()) { - range.setStart(range.startContainer.firstChild()); - } - if (range.endContainer.hasChildren()) { - range.setEnd(range.endContainer.lastChild()); - } - // If all targeted nodes are within a list of given type then unlist - // them. Otherwise, convert them to the given list type. - const targetedNodes = range.targetedNodes(); - const ancestors = targetedNodes.map(node => node.closest(ListNode)); - const targetedLists = ancestors.filter(list => !!list); - if ( - targetedLists.length === targetedNodes.length && - targetedLists.every(list => list.listType === type) - ) { - // Unlist the targeted nodes from all its list ancestors. - while (range.start.ancestor(ListNode)) { - const nodesToUnlist = range.split(ListNode); - for (const list of nodesToUnlist) { - for (const child of list.childVNodes) { - // TODO: automatically invalidate `li-attributes`. - child.modifiers.remove(ListItemAttributes); - } - list.unwrap(); - } - } - } else if (targetedLists.length === targetedNodes.length) { - // If all nodes are in lists, convert the targeted list - // nodes to the given list type. - const lists = distinct(targetedLists); - const listsToConvert = lists.filter(l => l.listType !== type); - for (const list of listsToConvert) { - let newList = new ListNode({ listType: type }); - list.replaceWith(newList); + const bounds = VRange.clone(params.context.range); + const range = new VRange(this.editor, bounds); - // If the new list is after or before a list of the same - // type, merge them. Example: - //
  1. a
  1. b
- // =>
  1. a
  2. b
). - const previousSibling = newList.previousSibling(); - if (previousSibling && previousSibling.is(ListNode[type])) { - newList.mergeWith(previousSibling); - newList = previousSibling; - } - const nextSibling = newList.nextSibling(); - if (nextSibling && nextSibling.is(ListNode[type])) { - nextSibling.mergeWith(newList); + // Extend the range to cover the entirety of its containers. + if (range.startContainer.hasChildren()) { + range.setStart(range.startContainer.firstChild()); + } + if (range.endContainer.hasChildren()) { + range.setEnd(range.endContainer.lastChild()); + } + // If all targeted nodes are within a list of given type then unlist + // them. Otherwise, convert them to the given list type. + const targetedNodes = range.targetedNodes(); + const ancestors = targetedNodes.map(node => node.closest(ListNode)); + const targetedLists = ancestors.filter(list => !!list); + if ( + targetedLists.length === targetedNodes.length && + targetedLists.every(list => list.listType === type) + ) { + // Unlist the targeted nodes from all its list ancestors. + while (range.start.ancestor(ListNode)) { + const nodesToUnlist = range.split(ListNode); + for (const list of nodesToUnlist) { + for (const child of list.childVNodes) { + // TODO: automatically invalidate `li-attributes`. + child.modifiers.remove(ListItemAttributes); } + list.unwrap(); } - } else { - // If only some nodes are in lists and other aren't then only - // wrap the ones that were not already in a list into a list of - // the given type. + } + } else if (targetedLists.length === targetedNodes.length) { + // If all nodes are in lists, convert the targeted list + // nodes to the given list type. + const lists = distinct(targetedLists); + const listsToConvert = lists.filter(l => l.listType !== type); + for (const list of listsToConvert) { let newList = new ListNode({ listType: type }); - const nodesToConvert = range.split(ListNode); - for (const node of nodesToConvert) { - // Merge top-level lists instead of nesting them. - if (node.is(ListNode)) { - node.mergeWith(newList); - } else { - node.wrap(newList); - } - } + list.replaceWith(newList); - // If the new list is after or before a list of the same type, - // merge them. Example: + // If the new list is after or before a list of the same + // type, merge them. Example: //
  1. a
  1. b
// =>
  1. a
  2. b
). const previousSibling = newList.previousSibling(); - if (previousSibling && previousSibling.is(ListNode[type])) { + if (previousSibling && ListNode[type](previousSibling)) { newList.mergeWith(previousSibling); newList = previousSibling; } - const nextSibling = newList.nextSibling(); - if (nextSibling && nextSibling.is(ListNode[type])) { + if (nextSibling && ListNode[type](nextSibling)) { nextSibling.mergeWith(newList); } } - }); + } else { + // If only some nodes are in lists and other aren't then only + // wrap the ones that were not already in a list into a list of + // the given type. + let newList = new ListNode({ listType: type }); + const nodesToConvert = range.split(ListNode); + for (const node of nodesToConvert) { + // Merge top-level lists instead of nesting them. + if (node instanceof ListNode) { + node.mergeWith(newList); + } else { + node.wrap(newList); + } + } + + // If the new list is after or before a list of the same type, + // merge them. Example: + //
  1. a
  1. b
+ // =>
  1. a
  2. b
). + const previousSibling = newList.previousSibling(); + if (previousSibling && ListNode[type](previousSibling)) { + newList.mergeWith(previousSibling); + newList = previousSibling; + } + + const nextSibling = newList.nextSibling(); + if (nextSibling && ListNode[type](nextSibling)) { + nextSibling.mergeWith(newList); + } + } + + range.remove(); } /** @@ -271,7 +272,7 @@ export class List extends JWPlugin */ indent(params: IndentParams): void { const range = params.context.range; - const items = range.targetedNodes(node => node.parent?.test(ListNode)); + const items = range.targetedNodes(node => node.parent instanceof ListNode); // Do not indent items of a targeted nested list, since they // will automatically be indented with their list ancestor. @@ -283,7 +284,7 @@ export class List extends JWPlugin const prev = item.previousSibling(); const next = item.nextSibling(); // Indent the item by putting it into a pre-existing list sibling. - if (prev && prev.is(ListNode)) { + if (prev && prev instanceof ListNode) { prev.append(item); // The two list siblings might be rejoinable now that the lower // level item breaking them into two different lists is no more. @@ -291,7 +292,7 @@ export class List extends JWPlugin if (ListNode[listType](next) && !itemsToIndent.includes(next)) { next.mergeWith(prev); } - } else if (next?.is(ListNode) && !itemsToIndent.includes(next)) { + } else if (next instanceof ListNode && !itemsToIndent.includes(next)) { next.prepend(item); } else { // If no other candidate exists then wrap it in a new ListNode. @@ -308,7 +309,7 @@ export class List extends JWPlugin */ outdent(params: OutdentParams): void { const range = params.context.range; - const items = range.targetedNodes(node => node.parent?.is(ListNode)); + const items = range.targetedNodes(node => node.parent instanceof ListNode); // Do not outdent items of a targeted nested list, since they // will automatically be outdented with their list ancestor. @@ -368,8 +369,8 @@ export class List extends JWPlugin while ( list && nextSibling && - list.is(ListNode) && - nextSibling.is(ListNode[list.listType]) + list instanceof ListNode && + ListNode[list.listType](nextSibling) ) { const nextList = list.lastChild(); const nextListSibling = nextSibling.firstChild(); diff --git a/packages/plugin-list/src/ListDomObjectRenderer.ts b/packages/plugin-list/src/ListDomObjectRenderer.ts index 8b3a15dec..ab3e67bfd 100644 --- a/packages/plugin-list/src/ListDomObjectRenderer.ts +++ b/packages/plugin-list/src/ListDomObjectRenderer.ts @@ -8,15 +8,16 @@ import { import '../assets/checklist.css'; import { VNode } from '../../core/src/VNodes/VNode'; -import { withRange, VRange } from '../../core/src/VRange'; +import { VRange } from '../../core/src/VRange'; import { ListItemAttributes } from './ListItemXmlDomParser'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class ListDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; engine: DomObjectRenderingEngine; predicate = ListNode; - async render(listNode: ListNode): Promise { + async render(listNode: ListNode, worker: RenderingEngineWorker): Promise { const list: DomObjectElement = { tag: listNode.listType === ListType.ORDERED ? 'OL' : 'UL', children: [], @@ -25,9 +26,11 @@ export class ListDomObjectRenderer extends NodeRenderer { list.attributes = { class: new Set(['checklist']) }; } const children = listNode.children(); - const domObjects = await this.engine.render(children); + const domObjects = await worker.render(children); for (const index in children) { - list.children.push(this._renderLi(listNode, children[index], domObjects[index])); + list.children.push( + this._renderLi(listNode, children[index], domObjects[index], worker), + ); } return list; @@ -36,6 +39,7 @@ export class ListDomObjectRenderer extends NodeRenderer { listNode: ListNode, listItem: VNode, rendering: DomObject, + worker: RenderingEngineWorker, ): DomObject | VNode { let li: DomObjectElement; // The node was wrapped in a "LI" but needs to be rendered as well. @@ -53,16 +57,25 @@ export class ListDomObjectRenderer extends NodeRenderer { tag: 'LI', children: [rendering], }; + // Mark as origin. If the listItem or the listNode change, the other are invalidate. + worker.depends(listItem, li); + worker.depends(li, listItem); } else { li = { tag: 'LI', children: [listItem], }; + // Mark as dependent. If the listItem change, the listNode are invalidate. But if the + // list change, the listItem will not invalidate. + worker.depends(li, listItem); } + worker.depends(li, listNode); + worker.depends(listNode, li); + // Render the node's attributes that were stored on the technical key // that specifies those attributes belong on the list item. - this.engine.renderAttributes(ListItemAttributes, listItem, li); + this.engine.renderAttributes(ListItemAttributes, listItem, li, worker); if (listNode.listType === ListType.ORDERED) { // Adapt numbering to skip previous list item @@ -70,20 +83,33 @@ export class ListDomObjectRenderer extends NodeRenderer { const previousIdentedList = listItem.previousSibling(); if (previousIdentedList instanceof ListNode) { const previousLis = previousIdentedList.previousSiblings( - sibling => !sibling.is(ListNode), + sibling => !(sibling instanceof ListNode), ); const value = Math.max(previousLis.length, 1) + 1; li.attributes.value = value.toString(); } } - if (listItem.is(ListNode)) { + if (listItem instanceof ListNode) { const style = li.attributes.style || {}; if (!style['list-style']) { style['list-style'] = 'none'; } li.attributes.style = style; - } else if (ListNode.CHECKLIST(listItem.parent)) { + + if (ListNode.CHECKLIST(listItem)) { + const prev = listItem.previousSibling(); + if (prev && !ListNode.CHECKLIST(prev)) { + // Add dependencie to check/uncheck with previous checklist item used as title. + worker.depends(prev, listItem); + worker.depends(listItem, prev); + } + } + } else if (ListNode.CHECKLIST(listNode)) { + // Add dependencie because the modifier on the listItem change the li rendering. + worker.depends(li, listItem); + worker.depends(listItem, listNode); + const className = ListNode.isChecked(listItem) ? 'checked' : 'unchecked'; if (li.attributes.class) { li.attributes.class.add(className); @@ -96,8 +122,7 @@ export class ListDomObjectRenderer extends NodeRenderer { if (ev.offsetX < 0) { ev.stopImmediatePropagation(); ev.preventDefault(); - withRange( - this.engine.editor, + this.engine.editor.withRange( VRange.at(listItem.firstChild() || listItem), range => { return this.engine.editor.execCommand('toggleChecked', { diff --git a/packages/plugin-list/src/ListItemXmlDomParser.ts b/packages/plugin-list/src/ListItemXmlDomParser.ts index 27203c3ff..4f1dd8e10 100644 --- a/packages/plugin-list/src/ListItemXmlDomParser.ts +++ b/packages/plugin-list/src/ListItemXmlDomParser.ts @@ -27,7 +27,11 @@ export class ListItemXmlDomParser extends AbstractParser { let inlinesContainer: VNode; // Parse the list item's attributes into the node's ListItemAttributes, // which will be read only by ListItemDomRenderer. - const itemModifiers = new Modifiers(this.engine.parseAttributes(item)); + const itemModifiers = new Modifiers(); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + itemModifiers.append(attributes); + } const Container = this.engine.editor.configuration.defaults.Container; for (let childIndex = 0; childIndex < children.length; childIndex++) { const domChild = children[childIndex]; diff --git a/packages/plugin-list/src/ListNode.ts b/packages/plugin-list/src/ListNode.ts index 0835a0418..7ba9f84bb 100644 --- a/packages/plugin-list/src/ListNode.ts +++ b/packages/plugin-list/src/ListNode.ts @@ -18,13 +18,13 @@ export class ListNode extends ContainerNode { // Typescript currently doesn't support using enum as keys in interfaces. // Source: https://github.com/microsoft/TypeScript/issues/13042 static ORDERED(node: VNode): node is ListNode { - return node && node.is(ListNode) && node.listType === ListType.ORDERED; + return node && node instanceof ListNode && node.listType === ListType.ORDERED; } static UNORDERED(node: VNode): node is ListNode { - return node && node.is(ListNode) && node.listType === ListType.UNORDERED; + return node && node instanceof ListNode && node.listType === ListType.UNORDERED; } static CHECKLIST(node: VNode): node is ListNode { - return node && node.is(ListNode) && node.listType === ListType.CHECKLIST; + return node && node instanceof ListNode && node.listType === ListType.CHECKLIST; } listType: ListType; constructor(params: ListNodeParams) { @@ -63,7 +63,7 @@ export class ListNode extends ContainerNode { */ static check(...nodes: VNode[]): void { for (const node of nodes) { - if (node.is(ListNode)) { + if (node instanceof ListNode) { // Check the list's children. ListNode.check(...node.children()); } else { @@ -72,7 +72,7 @@ export class ListNode extends ContainerNode { // Propagate to next indented list if any. const indentedChild = node.nextSibling(); - if (indentedChild && indentedChild.is(ListNode)) { + if (indentedChild && indentedChild instanceof ListNode) { ListNode.check(indentedChild); } } @@ -85,7 +85,7 @@ export class ListNode extends ContainerNode { */ static uncheck(...nodes: VNode[]): void { for (const node of nodes) { - if (node.is(ListNode)) { + if (node instanceof ListNode) { // Uncheck the list's children. ListNode.uncheck(...node.children()); } else { @@ -94,7 +94,7 @@ export class ListNode extends ContainerNode { // Propagate to next indented list. const indentedChild = node.nextSibling(); - if (indentedChild && indentedChild.is(ListNode)) { + if (indentedChild && indentedChild instanceof ListNode) { ListNode.uncheck(indentedChild); } } diff --git a/packages/plugin-list/src/ListXmlDomParser.ts b/packages/plugin-list/src/ListXmlDomParser.ts index d8d48cfd1..b064674e2 100644 --- a/packages/plugin-list/src/ListXmlDomParser.ts +++ b/packages/plugin-list/src/ListXmlDomParser.ts @@ -31,7 +31,13 @@ export class ListXmlDomParser extends AbstractParser { // Create the list node and parse its children and attributes. const list = new ListNode({ listType: type }); - list.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (type === ListType.CHECKLIST) { + attributes.classList.remove('checklist'); + } + if (attributes.length) { + list.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); list.append(...children); diff --git a/packages/plugin-list/test/List.test.ts b/packages/plugin-list/test/List.test.ts index b86b9f6e7..d6907ac34 100644 --- a/packages/plugin-list/test/List.test.ts +++ b/packages/plugin-list/test/List.test.ts @@ -74,8 +74,8 @@ describePlugin(List, testEditor => { describe('Checklist', () => { it('should parse a checked class', async () => { await testEditor(BasicEditor, { - contentBefore: '
  • a
', - contentAfter: '
  • a
', + contentBefore: '
  • a[]
', + contentAfter: '
  • a[]
', }); }); it('should parse a custom class', async () => { @@ -5889,7 +5889,7 @@ describePlugin(List, testEditor => {
    • cd
    • -
    • b
    • +
    • b
    • []
  • @@ -6214,7 +6214,7 @@ describePlugin(List, testEditor => {
    • cd
    • -
    • 0
    • +
    • 0
    • []
  • @@ -6304,7 +6304,7 @@ describePlugin(List, testEditor => {
    • cd
    • -
    • 0
    • +
    • 0
    • []
  • diff --git a/packages/plugin-metadata/src/MetadataXmlDomParser.ts b/packages/plugin-metadata/src/MetadataXmlDomParser.ts index d31d45f4c..f8976501a 100644 --- a/packages/plugin-metadata/src/MetadataXmlDomParser.ts +++ b/packages/plugin-metadata/src/MetadataXmlDomParser.ts @@ -25,7 +25,10 @@ export class MetadataXmlDomParser extends AbstractParser { async parse(item: Element): Promise { const technical = new MetadataNode({ htmlTag: nodeName(item) as 'SCRIPT' | 'STYLE' }); - technical.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + technical.modifiers.append(attributes); + } technical.contents = item.innerHTML; return [technical]; } diff --git a/packages/plugin-odoo-field/src/OdooField.ts b/packages/plugin-odoo-field/src/OdooField.ts index b5199ad75..d7f7d3cc4 100644 --- a/packages/plugin-odoo-field/src/OdooField.ts +++ b/packages/plugin-odoo-field/src/OdooField.ts @@ -8,6 +8,7 @@ import { ReactiveValue } from '../../utils/src/ReactiveValue'; import { OdooFieldMap } from './OdooFieldMap'; import { OdooMonetaryFieldXmlDomParser } from './OdooMonetaryFieldXmlDomParser'; import { OdooMonetaryFieldDomObjectRenderer } from './OdooMonetaryFieldDomObjectRenderer'; +import { makeVersionable } from '../../core/src/Memory/Versionable'; export interface OdooFieldDefinition { modelId: string; @@ -60,11 +61,11 @@ export class OdooField extends JWPlug } reactiveValue.set(value); - const reactiveOdooField = { + const reactiveOdooField = makeVersionable({ ...field, value: reactiveValue, isValid, - }; + }); this._registry.set(field, reactiveOdooField); } return this._registry.get(field); diff --git a/packages/plugin-odoo-field/src/OdooFieldDomObjectRenderer.ts b/packages/plugin-odoo-field/src/OdooFieldDomObjectRenderer.ts index 1b0fda5a8..60091bbb0 100644 --- a/packages/plugin-odoo-field/src/OdooFieldDomObjectRenderer.ts +++ b/packages/plugin-odoo-field/src/OdooFieldDomObjectRenderer.ts @@ -38,7 +38,8 @@ export class OdooFieldDomObjectRenderer extends NodeRenderer { // Instances of the field containing the range are artificially focused. const focusedField = this.engine.editor.selection.range.start.ancestor( ancestor => - ancestor.is(OdooFieldNode) && ancestor.fieldInfo.value === node.fieldInfo.value, + ancestor instanceof OdooFieldNode && + ancestor.fieldInfo.value === node.fieldInfo.value, ); if (focusedField) { classList.add('jw-focus'); diff --git a/packages/plugin-odoo-field/src/OdooFieldNode.ts b/packages/plugin-odoo-field/src/OdooFieldNode.ts index 6632864e0..f8033b4bf 100644 --- a/packages/plugin-odoo-field/src/OdooFieldNode.ts +++ b/packages/plugin-odoo-field/src/OdooFieldNode.ts @@ -1,5 +1,6 @@ import { VElement } from '../../core/src/VNodes/VElement'; import { OdooFieldInfo } from './OdooField'; +import { makeVersionable } from '../../core/src/Memory/Versionable'; export class OdooFieldNode extends VElement { fieldInfo: T; @@ -10,7 +11,7 @@ export class OdooFieldNode extends VEle }, ) { super(params); - this.fieldInfo = params.fieldInfo; + this.fieldInfo = makeVersionable(params.fieldInfo); } /** diff --git a/packages/plugin-odoo/src/Odoo.ts b/packages/plugin-odoo/src/Odoo.ts index d1df0eb33..dde6e4060 100644 --- a/packages/plugin-odoo/src/Odoo.ts +++ b/packages/plugin-odoo/src/Odoo.ts @@ -32,7 +32,11 @@ export class Odoo extends JWPlugin selected: (editor: JWEditor): boolean => { const range = editor.selection.range; const node = range.start.nextSibling() || range.start.previousSibling(); - return node && node.is(InlineNode) && !!node.modifiers.find(LinkFormat); + return ( + node && + node instanceof InlineNode && + !!node.modifiers.find(LinkFormat) + ); }, modifiers: [new Attributes({ class: 'fa fa-link fa-fw' })], }); diff --git a/packages/plugin-odoo/src/OdooFontAwesomeDomObjectRenderer.ts b/packages/plugin-odoo/src/OdooFontAwesomeDomObjectRenderer.ts index 6977999fe..5eb73043a 100644 --- a/packages/plugin-odoo/src/OdooFontAwesomeDomObjectRenderer.ts +++ b/packages/plugin-odoo/src/OdooFontAwesomeDomObjectRenderer.ts @@ -1,12 +1,16 @@ import { FontAwesomeNode } from '../../plugin-fontawesome/src/FontAwesomeNode'; import { DomObject } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; import { FontAwesomeDomObjectRenderer } from '../../plugin-fontawesome/src/FontAwesomeDomObjectRenderer'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class OdooFontAwesomeDomObjectRenderer extends FontAwesomeDomObjectRenderer { predicate = FontAwesomeNode; - async render(node: FontAwesomeNode): Promise { - const domObject: DomObject = await super.render(node); + async render( + node: FontAwesomeNode, + worker: RenderingEngineWorker, + ): Promise { + const domObject: DomObject = await super.render(node, worker); if (domObject && 'children' in domObject) { const fa = domObject.children[1]; if ('tag' in fa) { diff --git a/packages/plugin-odoo/src/OdooImageDomObjectRenderer.ts b/packages/plugin-odoo/src/OdooImageDomObjectRenderer.ts index 188a434d7..7b9106e90 100644 --- a/packages/plugin-odoo/src/OdooImageDomObjectRenderer.ts +++ b/packages/plugin-odoo/src/OdooImageDomObjectRenderer.ts @@ -1,12 +1,13 @@ import { ImageDomObjectRenderer } from '../../plugin-image/src/ImageDomObjectRenderer'; import { ImageNode } from '../../plugin-image/src/ImageNode'; import { DomObject } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class OdooImageDomObjectRenderer extends ImageDomObjectRenderer { predicate = ImageNode; - async render(node: ImageNode): Promise { - const image = await this.super.render(node); + async render(node: ImageNode, worker: RenderingEngineWorker): Promise { + const image = await this.super.render(node, worker); if (image && 'tag' in image) { const savedAttach = image.attach; const savedDetach = image.detach; diff --git a/packages/plugin-owl/src/OwlDomObjectRenderer.ts b/packages/plugin-owl/src/OwlDomObjectRenderer.ts index ac5718aaa..f6bbdc815 100644 --- a/packages/plugin-owl/src/OwlDomObjectRenderer.ts +++ b/packages/plugin-owl/src/OwlDomObjectRenderer.ts @@ -24,8 +24,8 @@ export class OwlDomObjectRenderer extends NodeRenderer { const placeholder = document.createElement('jw-placeholer'); document.body.appendChild(placeholder); - node.Component.env = this.env; - const component = new node.Component(null, node.props); + node.params.Component.env = this.env; + const component = new node.params.Component(null, node.params.props); components.set(node, component); await component.mount(placeholder); placeholder.remove(); diff --git a/packages/plugin-owl/src/OwlNode.ts b/packages/plugin-owl/src/OwlNode.ts index 0b7cf52db..501b706e9 100644 --- a/packages/plugin-owl/src/OwlNode.ts +++ b/packages/plugin-owl/src/OwlNode.ts @@ -1,6 +1,7 @@ import { OwlComponent } from './OwlComponent'; import { AtomicNode } from '../../core/src/VNodes/AtomicNode'; import { AbstractNodeParams } from '../../core/src/VNodes/AbstractNode'; +import { markNotVersionable } from '../../core/src/Memory/Versionable'; export interface OwlNodeParams extends AbstractNodeParams { Component: typeof OwlComponent; @@ -8,14 +9,13 @@ export interface OwlNodeParams extends AbstractNodeParams { } export class OwlNode extends AtomicNode { - Component: typeof OwlComponent; - props: Record; + params: OwlNodeParams; constructor(params: OwlNodeParams) { super(params); - this.Component = params.Component; - this.props = params.props; + markNotVersionable(params); + this.params = params; } get name(): string { - return super.name + ': ' + this.Component.name; + return super.name + ': ' + this.params.Component.name; } } diff --git a/packages/plugin-paragraph/src/ParagraphXmlDomParser.ts b/packages/plugin-paragraph/src/ParagraphXmlDomParser.ts index ea97cbbe1..9e842dbaa 100644 --- a/packages/plugin-paragraph/src/ParagraphXmlDomParser.ts +++ b/packages/plugin-paragraph/src/ParagraphXmlDomParser.ts @@ -13,7 +13,10 @@ export class ParagraphXmlDomParser extends AbstractParser { async parse(item: Element): Promise { const paragraph = new ParagraphNode(); - paragraph.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + paragraph.modifiers.append(attributes); + } const nodes = await this.engine.parse(...item.childNodes); paragraph.append(...nodes); return [paragraph]; diff --git a/packages/plugin-paragraph/test/Paragraph.test.ts b/packages/plugin-paragraph/test/Paragraph.test.ts index b7ad4a1f6..86219a153 100644 --- a/packages/plugin-paragraph/test/Paragraph.test.ts +++ b/packages/plugin-paragraph/test/Paragraph.test.ts @@ -6,7 +6,7 @@ describe('plugin-paragraph', () => { describe('ParagraphNode', () => { it('should create a paragraph', async () => { const vNode = new ParagraphNode(); - expect(vNode.is(ContainerNode)).to.equal(true); + expect(vNode instanceof ContainerNode).to.equal(true); expect(vNode.htmlTag).to.equal('P'); }); }); diff --git a/packages/plugin-pre/src/PreCharDomObjectRenderer.ts b/packages/plugin-pre/src/PreCharDomObjectRenderer.ts index 601f3e74c..9ba2b8ab7 100644 --- a/packages/plugin-pre/src/PreCharDomObjectRenderer.ts +++ b/packages/plugin-pre/src/PreCharDomObjectRenderer.ts @@ -4,21 +4,25 @@ import { CharNode } from '../../plugin-char/src/CharNode'; import { CharDomObjectRenderer } from '../../plugin-char/src/CharDomObjectRenderer'; import { DomObject } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; import { AbstractNode } from '../../core/src/VNodes/AbstractNode'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class PreCharDomObjectRenderer extends CharDomObjectRenderer { predicate: Predicate = (item: VNode): boolean => item instanceof CharNode && !!item.ancestor(PreNode); - async render(charNode: CharNode): Promise { - const domObject = await super.render(charNode); + async render(charNode: CharNode, worker: RenderingEngineWorker): Promise { + const domObject = await super.render(charNode, worker); this._renderInPre([domObject]); return domObject; } /** * Render the CharNode and convert unbreakable spaces into normal spaces. */ - async renderBatch(charNodes: CharNode[]): Promise { - const domObjects = await super.renderBatch(charNodes); + async renderBatch( + charNodes: CharNode[], + worker: RenderingEngineWorker, + ): Promise { + const domObjects = await super.renderBatch(charNodes, worker); this._renderInPre(domObjects); return domObjects; } diff --git a/packages/plugin-pre/src/PreSeparatorDomObjectRenderer.ts b/packages/plugin-pre/src/PreSeparatorDomObjectRenderer.ts index eaadbcf9d..3bb4770e5 100644 --- a/packages/plugin-pre/src/PreSeparatorDomObjectRenderer.ts +++ b/packages/plugin-pre/src/PreSeparatorDomObjectRenderer.ts @@ -7,6 +7,7 @@ import { DomObjectFragment, DomObjectElement, } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class PreSeparatorDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; @@ -14,20 +15,22 @@ export class PreSeparatorDomObjectRenderer extends NodeRenderer { predicate = (item: VNode): boolean => { const DefaultSeparator = this.engine.editor.configuration.defaults.Separator; - return item.is(DefaultSeparator) && !!item.ancestor(PreNode); + return item instanceof DefaultSeparator && !!item.ancestor(PreNode); }; /** * Render the VNode. */ - async render(node: VNode): Promise { - const separator = (await this.super.render(node)) as DomObjectElement | DomObjectFragment; + async render(node: VNode, worker: RenderingEngineWorker): Promise { + const separator = (await this.super.render(node, worker)) as + | DomObjectElement + | DomObjectFragment; let rendering: DomObject; if ('tag' in separator) { rendering = { text: '\n' }; } else { rendering = { text: '\n\n' }; - this.engine.locate([node, node], rendering); + worker.locate([node, node], rendering); } return rendering; } diff --git a/packages/plugin-pre/src/PreXmlDomParser.ts b/packages/plugin-pre/src/PreXmlDomParser.ts index a0c574f2a..1c4583523 100644 --- a/packages/plugin-pre/src/PreXmlDomParser.ts +++ b/packages/plugin-pre/src/PreXmlDomParser.ts @@ -13,7 +13,10 @@ export class PreXmlDomParser extends AbstractParser { async parse(item: Element): Promise { const pre = new PreNode(); - pre.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + pre.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); pre.append(...children); return [pre]; diff --git a/packages/plugin-renderer-dom-object/src/DefaultDomObjectRenderer.ts b/packages/plugin-renderer-dom-object/src/DefaultDomObjectRenderer.ts index c189e711e..1fc3002a2 100644 --- a/packages/plugin-renderer-dom-object/src/DefaultDomObjectRenderer.ts +++ b/packages/plugin-renderer-dom-object/src/DefaultDomObjectRenderer.ts @@ -12,16 +12,16 @@ export class DefaultDomObjectRenderer extends NodeRenderer { async render(node: VNode): Promise { let domObject: DomObject; if (node.tangible) { - if (node.is(VElement) && node.htmlTag[0] !== '#') { + if (node instanceof VElement && node.htmlTag[0] !== '#') { domObject = { tag: node.htmlTag, children: await this.engine.renderChildren(node), }; - } else if (node.test(FragmentNode)) { + } else if (node instanceof FragmentNode) { domObject = { children: await this.engine.renderChildren(node), }; - } else if (node.is(AtomicNode)) { + } else if (node instanceof AtomicNode) { domObject = { children: [] }; } else { domObject = { diff --git a/packages/plugin-renderer-dom-object/src/DomObjectRenderingEngine.ts b/packages/plugin-renderer-dom-object/src/DomObjectRenderingEngine.ts index 122c37624..36cdd4424 100644 --- a/packages/plugin-renderer-dom-object/src/DomObjectRenderingEngine.ts +++ b/packages/plugin-renderer-dom-object/src/DomObjectRenderingEngine.ts @@ -2,12 +2,15 @@ import { RenderingEngine, RenderingIdentifier } from '../../plugin-renderer/src/ import { DefaultDomObjectRenderer } from './DefaultDomObjectRenderer'; import { DefaultDomObjectModifierRenderer } from './DefaultDomObjectModifierRenderer'; import { VNode } from '../../core/src/VNodes/VNode'; -import { AtomicNode } from '../../core/src/VNodes/AtomicNode'; import { Attributes } from '../../plugin-xml/src/Attributes'; import { AbstractNode } from '../../core/src/VNodes/AbstractNode'; import { Modifier } from '../../core/src/Modifier'; import { NodeRenderer } from '../../plugin-renderer/src/NodeRenderer'; import { RuleProperty } from '../../core/src/Mode'; +import { + RenderingEngineWorker, + RenderingEngineCache, +} from '../../plugin-renderer/src/RenderingEngineCache'; /** * Renderer a node can define the location when define the nodes attributes. @@ -226,6 +229,7 @@ export class DomObjectRenderingEngine extends RenderingEngine { static readonly id: RenderingIdentifier = 'dom/object'; static readonly defaultRenderer = DefaultDomObjectRenderer; static readonly defaultModifierRenderer = DefaultDomObjectModifierRenderer; + /** * Render the attributes of the given VNode onto the given DOM Element. * @@ -233,7 +237,12 @@ export class DomObjectRenderingEngine extends RenderingEngine { * @param node * @param element */ - renderAttributes(Class: T, node: VNode, item: DomObject): void { + renderAttributes( + Class: T, + node: VNode, + item: DomObject, + worker: RenderingEngineWorker, + ): void { if ('tag' in item) { if (!item.attributes) item.attributes = {}; const attributes = node.modifiers.find(Class); @@ -251,7 +260,7 @@ export class DomObjectRenderingEngine extends RenderingEngine { attr[name] = attributes.get(name); } } - this._addOrigin(attributes, item); + worker.depends(item, attributes); } } } @@ -259,17 +268,8 @@ export class DomObjectRenderingEngine extends RenderingEngine { * @overwrite */ async renderChildren(node: VNode): Promise> { - const children: Array = []; - if (node.hasChildren()) { - for (const child of node.childVNodes) { - if (child.tangible) { - children.push(child); - } - } - } else if ( - !node.is(AtomicNode) && - this.editor.mode.is(node, RuleProperty.ALLOW_EMPTY) !== true - ) { + const children: Array = node.children(); + if (!children.length && this.editor.mode.is(node, RuleProperty.ALLOW_EMPTY) !== true) { children.push({ tag: 'BR' }); } return children; @@ -289,7 +289,10 @@ export class DomObjectRenderingEngine extends RenderingEngine { * * @param domObject */ - async resolveChildren(domObject: DomObject): Promise { + async resolveChildren( + domObject: DomObject, + worker: RenderingEngineWorker, + ): Promise { const stack = [domObject]; for (const domObject of stack) { if ('children' in domObject) { @@ -297,7 +300,7 @@ export class DomObjectRenderingEngine extends RenderingEngine { const childNodes = domObject.children.filter( child => child instanceof AbstractNode, ) as VNode[]; - const domObjects = await this.render(childNodes); + const domObjects = await worker.render(childNodes); for (const index in domObject.children) { const child = domObject.children[index]; let childObject: DomObject; @@ -320,9 +323,13 @@ export class DomObjectRenderingEngine extends RenderingEngine { * * @override */ - renderBatched(nodes: VNode[], rendered?: NodeRenderer): Promise[] { - const renderingUnits = this._getRenderingUnits(nodes, rendered); - return this._renderBatched(renderingUnits); + renderBatched( + cache: RenderingEngineCache, + nodes: VNode[], + rendered?: NodeRenderer, + ): Promise[] { + const renderingUnits = this._getRenderingUnits(cache, nodes, rendered); + return this._renderBatched(cache, renderingUnits); } /** * Group the nodes by format and by renderer and call 'renderBatch' with @@ -334,59 +341,58 @@ export class DomObjectRenderingEngine extends RenderingEngine { * * @param renderingUnits */ - private _renderBatched(renderingUnits: RenderingBatchUnit[]): Promise[] { + private _renderBatched( + cache: RenderingEngineCache, + renderingUnits: RenderingBatchUnit[], + ): Promise[] { const batchPromises: Promise[] = []; - - // Remove modifier who render nothing. - for (const unit of renderingUnits) { - for (let i = unit[1].length - 1; i >= 0; i--) { - if (this._modifierIsSameAs(unit[1][i], null)) { - unit[1].splice(i, 1); - } - } - } - for (let unitIndex = 0; unitIndex < renderingUnits.length; unitIndex++) { let nextUnitIndex = unitIndex; const unit = renderingUnits[unitIndex]; - if (unit[1].length) { + if (unit && unit[1].length) { // Group same formating. - const modifier = unit[1].shift(); - const newRenderingUnits: RenderingBatchUnit[] = [unit]; - let lastUnit: RenderingBatchUnit = unit; + const modifier = unit[1][0]; + let lastUnit: RenderingBatchUnit = [unit[0], unit[1].slice(1), unit[2]]; + const newRenderingUnits: RenderingBatchUnit[] = [lastUnit]; let nextUnit: RenderingBatchUnit; while ( (nextUnit = renderingUnits[nextUnitIndex + 1]) && lastUnit[0].parent === nextUnit[0].parent && nextUnit[1].length && - this._modifierIsSameAs(modifier, nextUnit[1]?.[0]) + this._modifierIsSameAs(cache, modifier, nextUnit[1]?.[0]) ) { nextUnitIndex++; lastUnit = renderingUnits[nextUnitIndex]; newRenderingUnits.push([lastUnit[0], lastUnit[1].slice(1), lastUnit[2]]); } // Render wrapped nodes. - const promises = this._renderBatched(newRenderingUnits); + const promises = this._renderBatched(cache, newRenderingUnits); const nodes: VNode[] = newRenderingUnits.map(u => u[0]); const modifierPromise = Promise.all(promises).then(async () => { const domObjects: DomObject[] = []; - for (const domObject of nodes.map(node => this.renderings.get(node))) { + for (const domObject of nodes.map(node => cache.renderings.get(node))) { if (!domObjects.includes(domObject)) { domObjects.push(domObject); } } // Create format. - const modifierRenderer = this.getCompatibleModifierRenderer(modifier, nodes); - const wraps = await modifierRenderer.render(modifier, domObjects, nodes); + const modifierRenderer = this.getCompatibleModifierRenderer(cache, modifier); + const wraps = await modifierRenderer.render( + modifier, + domObjects, + nodes, + cache.worker, + ); // Add origins. for (const wrap of wraps) { const stack = [wrap]; for (const domObject of stack) { - const origins = this.from.get(domObject); + const origins = cache.renderingDependent.get(domObject); if (origins) { for (const origin of origins) { - this._addOrigin(origin, wrap); + this._depends(cache, origin, wrap); + this._depends(cache, wrap, origin); } } if ('children' in domObject) { @@ -397,17 +403,20 @@ export class DomObjectRenderingEngine extends RenderingEngine { } } } - this._addOrigin(modifier, wrap); + this._depends(cache, modifier, wrap); + this._depends(cache, wrap, modifier); } // Update the renderings promise. for (const node of nodes) { - const wrap = wraps.find(wrap => this.from.get(wrap)?.includes(node)); - this.renderings.set(node, wrap); + const wrap = wraps.find(wrap => + cache.renderingDependent.get(wrap)?.has(node), + ); + cache.renderings.set(node, wrap); } }); for (const node of nodes) { - this.renderingPromises.set(node, modifierPromise); + cache.renderingPromises.set(node, modifierPromise); } batchPromises.push(modifierPromise); } else { @@ -430,21 +439,22 @@ export class DomObjectRenderingEngine extends RenderingEngine { if (currentRenderer) { const promise = new Promise(resolve => { Promise.resolve().then(() => { - currentRenderer.renderBatch(siblings).then(domObjects => { + currentRenderer.renderBatch(siblings, cache.worker).then(domObjects => { // Set the value, add origins and locations. for (const index in siblings) { const node = siblings[index]; const value = domObjects[index]; - this._addOrigin(node, value); - this._addDefaultLocation(node, value); - this.renderings.set(node, value); + this._depends(cache, node, value); + this._depends(cache, value, node); + this._addDefaultLocation(cache, node, value); + cache.renderings.set(node, value); } resolve(); }); }); }); for (const sibling of siblings) { - this.renderingPromises.set(sibling, promise); + cache.renderingPromises.set(sibling, promise); } batchPromises.push(promise); nextUnitIndex--; @@ -463,54 +473,66 @@ export class DomObjectRenderingEngine extends RenderingEngine { * @param rendered */ private _getRenderingUnits( + cache: RenderingEngineCache, nodes: VNode[], rendered?: NodeRenderer, ): RenderingBatchUnit[] { // Consecutive char nodes are rendered in same time. const renderingUnits: RenderingBatchUnit[] = []; + const setNodes = new Set(nodes); // Use set for perf. + const selected = new Set(); for (const node of nodes) { if (selected.has(node)) { continue; } - if (node.hasChildren()) { - const renderer = this.getCompatibleRenderer(node, rendered); - if (renderer) { - const modifiers = node.modifiers.map(modifer => modifer); - renderingUnits.push([node, modifiers, renderer]); - } - selected.add(node); - } else { - const siblings = node.parent?.children(); - if (!siblings || siblings.indexOf(node) === -1) { - // Render node like MarkerNode. - const renderer = this.getCompatibleRenderer(node, rendered); - if (renderer) { - const modifiers = node.modifiers.map(modifer => modifer); - renderingUnits.push([node, modifiers, renderer]); - } - selected.add(node); - } else { - for (const sibling of siblings) { - if (!selected.has(sibling)) { - const modifiers = sibling.modifiers.map(modifer => modifer); - const renderer = this.getCompatibleRenderer(sibling, rendered); - if (renderer) { - renderingUnits.push([sibling, modifiers, renderer]); - } + const parent = node.parent; + if (parent) { + const markers: VNode[] = []; + parent.childVNodes.forEach(sibling => { + // Filter and sort the ndoes. + if (setNodes.has(sibling)) { + if (sibling.tangible) { + renderingUnits.push(this._createUnit(cache, sibling, rendered)); + } else { + // Not tangible node are add after other nodes (don't cut text node). + markers.push(sibling); } selected.add(sibling); + } else if (sibling.tangible) { + renderingUnits.push(null); } + }); + for (const marker of markers) { + renderingUnits.push(this._createUnit(cache, marker, rendered)); } + } else { + renderingUnits.push(this._createUnit(cache, node, rendered)); } } return renderingUnits; } - protected _addDefaultLocation(node: VNode, domObject: DomObject): void { + private _createUnit( + cache: RenderingEngineCache, + node: VNode, + rendered?: NodeRenderer, + ): RenderingBatchUnit { + const renderer = cache.worker.getCompatibleRenderer(node, rendered); + // Remove modifier who render nothing. + const modifiers = node.modifiers.filter( + modifer => !this._modifierIsSameAs(cache, modifer, null), + ); + return [node, modifiers, renderer]; + } + protected _addDefaultLocation( + cache: RenderingEngineCache, + node: VNode, + domObject: DomObject, + ): void { let located = false; const stack = [domObject]; for (const object of stack) { - if (this.locations.get(object)) { + if (cache.locations.get(object)) { located = true; break; } @@ -526,13 +548,7 @@ export class DomObjectRenderingEngine extends RenderingEngine { } } if (!located) { - this.locations.set(domObject, [node]); + cache.locations.set(domObject, [node]); } } - private _modifierIsSameAs(modifierA: Modifier | void, modifierB: Modifier | void): boolean { - return ( - (!modifierA || modifierA.isSameAs(modifierB)) && - (!modifierB || modifierB.isSameAs(modifierA)) - ); - } } diff --git a/packages/plugin-renderer/src/ModifierRenderer.ts b/packages/plugin-renderer/src/ModifierRenderer.ts index 5b1f9ee7f..af968486b 100644 --- a/packages/plugin-renderer/src/ModifierRenderer.ts +++ b/packages/plugin-renderer/src/ModifierRenderer.ts @@ -3,6 +3,7 @@ import { RenderingEngine } from './RenderingEngine'; import { Modifier } from '../../core/src/Modifier'; import { ModifierPredicate } from '../../core/src/Modifier'; import { VNode } from '../../core/src/VNodes/VNode'; +import { RenderingEngineWorker } from './RenderingEngineCache'; class SuperModifierRenderer { constructor(public renderer: ModifierRenderer) {} @@ -15,13 +16,14 @@ class SuperModifierRenderer { * @param contents * @param batch */ - render(modifier: Modifier, contents: T[], batch: VNode[]): Promise { - const nextRenderer = this.renderer.engine.getCompatibleModifierRenderer( - modifier, - batch, - this.renderer, - ); - return nextRenderer?.render(modifier, contents, batch); + render( + modifier: Modifier, + contents: T[], + batch: VNode[], + worker: RenderingEngineWorker, + ): Promise { + const nextRenderer = worker.getCompatibleModifierRenderer(modifier, this.renderer); + return nextRenderer?.render(modifier, contents, batch, worker); } } @@ -43,7 +45,12 @@ export abstract class ModifierRenderer { * @param contents * @param batch */ - abstract render(modifier: Modifier, renderings: T[], batch: VNode[]): Promise; + abstract render( + modifier: Modifier, + renderings: T[], + batch: VNode[], + worker: RenderingEngineWorker, + ): Promise; } export interface ModifierRenderer { diff --git a/packages/plugin-renderer/src/NodeRenderer.ts b/packages/plugin-renderer/src/NodeRenderer.ts index 6082fb529..a32322799 100644 --- a/packages/plugin-renderer/src/NodeRenderer.ts +++ b/packages/plugin-renderer/src/NodeRenderer.ts @@ -1,6 +1,7 @@ import { RenderingIdentifier } from './RenderingEngine'; import { RenderingEngine } from './RenderingEngine'; import { VNode, Predicate } from '../../core/src/VNodes/VNode'; +import { RenderingEngineWorker } from './RenderingEngineCache'; class SuperRenderer { constructor(public renderer: NodeRenderer) {} @@ -9,18 +10,18 @@ class SuperRenderer { * * @param node */ - render(node: VNode): Promise { - const nextRenderer = this.renderer.engine.getCompatibleRenderer(node, this.renderer); - return nextRenderer?.render(node); + render(node: VNode, worker: RenderingEngineWorker): Promise { + const nextRenderer = worker.getCompatibleRenderer(node, this.renderer); + return nextRenderer?.render(node, worker); } /** * Render the given group of nodes. * * @param node */ - async renderBatch(nodes: VNode[]): Promise { - await Promise.all(this.renderer.engine.renderBatched(nodes, this.renderer)); - return nodes.map(node => this.renderer.engine.renderings.get(node)); + async renderBatch(nodes: VNode[], worker: RenderingEngineWorker): Promise { + await Promise.all(worker.renderBatched(nodes, this.renderer)); + return nodes.map(node => worker.getRendering(node)); } } @@ -42,7 +43,7 @@ export abstract class NodeRenderer { * * @param node */ - abstract render(node: VNode): Promise; + abstract render(node: VNode, worker: RenderingEngineWorker): Promise; /** * Render the given group of nodes. * The indices of the DomObject list match the indices of the given nodes @@ -50,8 +51,8 @@ export abstract class NodeRenderer { * * @param node */ - renderBatch(nodes: VNode[]): Promise { - return Promise.all(nodes.map(node => this.render(node))); + renderBatch(nodes: VNode[], worker: RenderingEngineWorker): Promise { + return Promise.all(nodes.map(node => this.render(node, worker))); } } diff --git a/packages/plugin-renderer/src/Renderer.ts b/packages/plugin-renderer/src/Renderer.ts index ee6579336..188bdb870 100644 --- a/packages/plugin-renderer/src/Renderer.ts +++ b/packages/plugin-renderer/src/Renderer.ts @@ -15,7 +15,27 @@ export class Renderer extends JWPlugi }; engines: Record = {}; + /** + * @override + */ + stop(): Promise { + this.engines = {}; + return super.stop(); + } + + /** + * Render the VNode and return the rendering. + * + * @param renderingId + * @param node + */ async render(renderingId: string, node: VNode): Promise; + /** + * Render the VNodes and return a list of renderings. + * + * @param renderingId + * @param nodes + */ async render(renderingId: string, nodes: VNode[]): Promise; async render(renderingId: string, nodes: VNode | VNode[]): Promise { const engine = this.engines[renderingId] as RenderingEngine; @@ -23,11 +43,12 @@ export class Renderer extends JWPlugi // The caller might want to fallback on another rendering. return; } - engine.clear(); if (nodes instanceof Array) { - return engine.render(nodes); + const cache = await engine.render(nodes); + nodes.map(node => cache.renderings.get(node)); } else { - return (await engine.render([nodes]))[0]; + const cache = await engine.render([nodes]); + return cache.renderings.get(nodes); } } diff --git a/packages/plugin-renderer/src/RenderingEngine.ts b/packages/plugin-renderer/src/RenderingEngine.ts index 9677f3fad..0d1cc1962 100644 --- a/packages/plugin-renderer/src/RenderingEngine.ts +++ b/packages/plugin-renderer/src/RenderingEngine.ts @@ -4,9 +4,13 @@ import JWEditor from '../../core/src/JWEditor'; import { Modifier } from '../../core/src/Modifier'; import { ModifierRenderer, ModifierRendererConstructor } from './ModifierRenderer'; import { NodeRenderer, RendererConstructor } from './NodeRenderer'; +import { AbstractNode } from '../../core/src/VNodes/AbstractNode'; +import { RenderingEngineCache, ModifierPairId } from './RenderingEngineCache'; export type RenderingIdentifier = string; +let modifierId = 0; + export class RenderingEngine { static readonly id: RenderingIdentifier; static readonly extends: RenderingIdentifier[] = []; @@ -15,10 +19,6 @@ export class RenderingEngine { readonly editor: JWEditor; readonly renderers: NodeRenderer[] = []; readonly modifierRenderers: ModifierRenderer[] = []; - readonly renderings: Map = new Map(); - readonly renderingPromises: Map> = new Map(); - readonly locations: Map = new Map(); - readonly from: Map> = new Map(); constructor(editor: JWEditor) { this.editor = editor; @@ -71,16 +71,38 @@ export class RenderingEngine { /** * Render the given node. If a prior rendering already exists for this node * in this run, return it directly. + * The cache are automaticaly invalidate if the nodes are not linked to the + * memory (linked to a layout root for eg) * - * @param node + * @param nodes */ - async render(nodes: VNode[]): Promise { + async render( + nodes: VNode[], + cache?: RenderingEngineCache, + ): Promise> { + if (!cache) { + cache = new RenderingEngineCache(); + cache.worker = { + depends: this._depends.bind(this, cache), + renderBatched: this.renderBatched.bind(this, cache), + getCompatibleRenderer: this.getCompatibleRenderer.bind(this, cache), + getCompatibleModifierRenderer: this.getCompatibleModifierRenderer.bind(this, cache), + locate: this.locate.bind(this, cache), + getRendering: (node): T => cache.renderings.get(node), + render: async (nodes: VNode[]): Promise => { + await this.render(nodes, cache); + return nodes.map(node => cache.renderings.get(node)); + }, + }; + } + const promises = this.renderBatched( - nodes.filter(node => !this.renderingPromises.get(node)), + cache, + nodes.filter(node => !cache.renderingPromises.get(node)), ); await Promise.all(promises); // wait the newest promises - await Promise.all(nodes.map(node => this.renderingPromises.get(node))); // wait indifidual promise - return nodes.map(node => this.renderings.get(node)); // return result + await Promise.all(nodes.map(node => cache.renderingPromises.get(node))); // wait indifidual promise + return cache; } /** * Indicates the location of the nodes in the rendering performed. @@ -94,8 +116,8 @@ export class RenderingEngine { * @param nodes * @param rendering */ - locate(nodes: VNode[], value: T): void { - this.locations.set(value, nodes); + locate(cache: RenderingEngineCache, nodes: VNode[], value: T): void { + cache.locations.set(value, nodes); } /** * Group the nodes and call the renderer 'renderBatch' method with the @@ -109,18 +131,23 @@ export class RenderingEngine { * @param nodes * @param rendered */ - renderBatched(nodes: VNode[], rendered?: NodeRenderer): Promise[] { + renderBatched( + cache: RenderingEngineCache, + nodes: VNode[], + rendered?: NodeRenderer, + ): Promise[] { const promises: Promise[] = []; for (const node of nodes) { - const renderer = this.getCompatibleRenderer(node, rendered); - const renderings = renderer.renderBatch(nodes); + const renderer = cache.worker.getCompatibleRenderer(node, rendered); + const renderings = renderer.renderBatch(nodes, cache.worker); const promise = renderings.then(values => { const value = values[0]; - this._addOrigin(node, value); - this._addDefaultLocation(node, value); - this.renderings.set(node, value); + this._depends(cache, node, value); + this._depends(cache, value, node); + this._addDefaultLocation(cache, node, value); + cache.renderings.set(node, value); }); - this.renderingPromises.set(node, promise); + cache.renderingPromises.set(node, promise); promises.push(promise); } return promises; @@ -132,13 +159,25 @@ export class RenderingEngine { * @param node * @param previousRenderer */ - getCompatibleRenderer(node: VNode, previousRenderer: NodeRenderer): NodeRenderer { + getCompatibleRenderer( + cache: RenderingEngineCache, + node: VNode, + previousRenderer: NodeRenderer, + ): NodeRenderer { + let cacheCompatible = cache.cachedCompatibleRenderer.get(node); + if (!cacheCompatible) { + cacheCompatible = new Map(); + cache.cachedCompatibleRenderer.set(node, cacheCompatible); + } else if (cacheCompatible.get(previousRenderer)) { + return cacheCompatible.get(previousRenderer); + } let nextRendererIndex = this.renderers.indexOf(previousRenderer) + 1; let nextRenderer: NodeRenderer; do { nextRenderer = this.renderers[nextRendererIndex]; nextRendererIndex++; } while (nextRenderer && !node.test(nextRenderer.predicate)); + cacheCompatible.set(previousRenderer, nextRenderer); return nextRenderer; } /** @@ -149,10 +188,18 @@ export class RenderingEngine { * @param previousRenderer */ getCompatibleModifierRenderer( + cache: RenderingEngineCache, modifier: Modifier, - nodes: VNode[], previousRenderer?: ModifierRenderer, ): ModifierRenderer { + let cacheCompatible = cache.cachedCompatibleModifierRenderer.get(modifier); + if (!cacheCompatible) { + cacheCompatible = new Map(); + cache.cachedCompatibleModifierRenderer.set(modifier, cacheCompatible); + } else if (cacheCompatible.get(previousRenderer)) { + return cacheCompatible.get(previousRenderer); + } + let nextRendererIndex = this.modifierRenderers.indexOf(previousRenderer) + 1; let nextRenderer: ModifierRenderer; do { @@ -162,32 +209,100 @@ export class RenderingEngine { nextRenderer.predicate && !(isConstructor(nextRenderer.predicate, Modifier) ? modifier instanceof nextRenderer.predicate - : nextRenderer.predicate(modifier, nodes)) + : nextRenderer.predicate(modifier)) ); + cacheCompatible.set(previousRenderer, nextRenderer); return nextRenderer; } - /** - * Clear the cache. - * - */ - clear(): void { - this.renderings.clear(); - this.renderingPromises.clear(); - this.locations.clear(); - this.from.clear(); - } - protected _addOrigin(node: VNode | Modifier, value: T): void { - const from = this.from.get(value); - if (from) { - if (!from.includes(node)) { - from.push(node); - } + protected _depends( + cache: RenderingEngineCache, + dependent: T | VNode | Modifier, + dependency: T | VNode | Modifier, + ): void { + let dNode: VNode | Modifier; + let dRendering: T; + let dyNode: VNode | Modifier; + let dyRendering: T; + if (dependent instanceof AbstractNode || dependent instanceof Modifier) { + dNode = dependent; } else { - this.from.set(value, [node]); + dRendering = dependent; + } + if (dependency instanceof AbstractNode || dependency instanceof Modifier) { + dyNode = dependency; + } else { + dyRendering = dependency; + } + + if (dNode) { + if (dyNode) { + const linked = cache.linkedNodes.get(dyNode); + if (linked) { + linked.add(dNode); + } else { + cache.linkedNodes.set(dyNode, new Set([dNode])); + } + } else { + const from = cache.renderingDependent.get(dyRendering); + if (from) { + from.add(dNode); + } else { + cache.renderingDependent.set(dyRendering, new Set([dNode])); + } + } + } else if (dyNode) { + const linked = cache.nodeDependent.get(dyNode); + if (linked) { + linked.add(dRendering); + } else { + cache.nodeDependent.set(dyNode, new Set([dRendering])); + } } } - protected _addDefaultLocation(node: VNode, value: T): void { - this.locations.set(value, [node]); + protected _addDefaultLocation(cache: RenderingEngineCache, node: VNode, value: T): void { + cache.locations.set(value, [node]); + } + protected _modifierIsSameAs( + cache: RenderingEngineCache, + modifierA: Modifier | void, + modifierB: Modifier | void, + ): boolean { + if (modifierA === modifierB) { + return true; + } + let idA = modifierA ? cache.cachedModifierId.get(modifierA) : 'null'; + if (!idA) { + idA = ++modifierId; + cache.cachedModifierId.set(modifierA, idA); + } + let idB = modifierB ? cache.cachedModifierId.get(modifierB) : 'null'; + if (!idB) { + idB = ++modifierId; + cache.cachedModifierId.set(modifierB, idB); + } + const key: ModifierPairId = idA + '-' + idB; + if (key in cache.cachedIsSameAsModifier) { + return cache.cachedIsSameAsModifier[key]; + } + const reverseKey: ModifierPairId = idB + '-' + idA; + if (reverseKey in cache.cachedIsSameAsModifier) { + return cache.cachedIsSameAsModifier[reverseKey]; + } + const isSame = + (!modifierA || modifierA.isSameAs(modifierB)) && + (!modifierB || modifierB.isSameAs(modifierA)); + cache.cachedIsSameAsModifier[key] = isSame; + if (!cache.cachedIsSameAsModifierIds[idA]) { + cache.cachedIsSameAsModifierIds[idA] = [key]; + } else { + cache.cachedIsSameAsModifierIds[idA].push(key); + } + if (!cache.cachedIsSameAsModifierIds[idB]) { + cache.cachedIsSameAsModifierIds[idB] = [key]; + } else { + cache.cachedIsSameAsModifierIds[idB].push(key); + } + return cache.cachedIsSameAsModifier[key]; } } diff --git a/packages/plugin-renderer/src/RenderingEngineCache.ts b/packages/plugin-renderer/src/RenderingEngineCache.ts new file mode 100644 index 000000000..48e23b08b --- /dev/null +++ b/packages/plugin-renderer/src/RenderingEngineCache.ts @@ -0,0 +1,76 @@ +import { VNode } from '../../core/src/VNodes/VNode'; +import { Modifier } from '../../core/src/Modifier'; +import { ModifierRenderer } from './ModifierRenderer'; +import { NodeRenderer } from './NodeRenderer'; + +export type ModifierId = number; +export type ModifierPairId = string; + +export interface RenderingEngineWorker { + /** + * Invalidate the first if the second is invalidated. + * + * If the given dependent is VNode or Modifier and the dependency is a + * VNode or Modifier when the dependency is invalidated the dependent will + * be invalidated. + * + * If the given dependent is VNode or Modifier and the dependency is a + * rendering, when the rendering is invalidated every dependent will be + * invalidated. + * + * If the given dependent is rendering when the VNode or Modifier is + * invalidated the rendering will be invalidated. + * + * @param dependent + * @param dependency + */ + depends(dependent: VNode | Modifier, dependency: VNode | Modifier): void; + depends(dependent: VNode | Modifier, dependency: T): void; + depends(dependent: T, dependency: VNode | Modifier): void; + depends(dependent: T | VNode | Modifier, dependency: T | VNode | Modifier): void; + + renderBatched(nodes: VNode[], rendered?: NodeRenderer): Promise[]; + getCompatibleRenderer(node: VNode, previousRenderer: NodeRenderer): NodeRenderer; + getCompatibleModifierRenderer( + modifier: Modifier, + previousRenderer?: ModifierRenderer, + ): ModifierRenderer; + getRendering(node: VNode): T; + locate(nodes: VNode[], value: T): void; + render(nodes: VNode[]): Promise; +} + +export class RenderingEngineCache { + // Rendering created by a VNode. + renderings = new Map(); + // Promise resolved when the renderings is ready. We can have a value in renderings before the + // promise is resolved but it's not the complete value (for eg: we create the node and an other + // renderer add the attributes on this node) + renderingPromises = new Map>(); + + // VNodes locations in a rendering (by default the rendering is equal to the location). + locations = new Map(); + // List of VNode and Modifiers linked to a rendering. + // When the rendering is invalidated every VNode or Modifier will be invalidated. + renderingDependent = new Map>(); + // When the VNode or Modifier is invalidated every rendering will be invalidated. + nodeDependent = new Map>(); + // When the dependency is invalidated every dependents will be invalidated. + linkedNodes = new Map>(); + + // Cache for founded renderer. + cachedCompatibleRenderer = new Map, NodeRenderer>>(); + cachedCompatibleModifierRenderer = new Map< + Modifier, + Map, ModifierRenderer> + >(); + + // Cache to compare modifiers. + cachedModifierId = new Map(); + cachedIsSameAsModifier: Record = {}; + // Used to invalidate the cachedIsSameAsModifier values. + cachedIsSameAsModifierIds: Record = {}; + + // Worker send to renderers method 'render'. + worker?: RenderingEngineWorker; +} diff --git a/packages/plugin-shadow/src/ShadowDomObjectRenderer.ts b/packages/plugin-shadow/src/ShadowDomObjectRenderer.ts index 79b4e9013..70578b6ca 100644 --- a/packages/plugin-shadow/src/ShadowDomObjectRenderer.ts +++ b/packages/plugin-shadow/src/ShadowDomObjectRenderer.ts @@ -15,7 +15,9 @@ export class ShadowDomObjectRenderer extends NodeRenderer { const domObject: DomObject = { tag: 'JW-SHADOW', shadowRoot: true, - children: shadow.childVNodes.filter(child => child.tangible || child.is(MetadataNode)), + children: shadow.childVNodes.filter( + child => child.tangible || child instanceof MetadataNode, + ), }; return domObject; } diff --git a/packages/plugin-shadow/src/ShadowHtmlDomParser.ts b/packages/plugin-shadow/src/ShadowHtmlDomParser.ts index 01d82d3c8..6c4a016d0 100644 --- a/packages/plugin-shadow/src/ShadowHtmlDomParser.ts +++ b/packages/plugin-shadow/src/ShadowHtmlDomParser.ts @@ -28,7 +28,10 @@ export class ShadowHtmlDomParser extends AbstractParser { return [shadow]; } else { const element = new VElement({ htmlTag: nodeName(item) }); - element.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + element.modifiers.append(attributes); + } element.append(shadow); return [element]; } diff --git a/packages/plugin-shadow/src/ShadowXmlDomParser.ts b/packages/plugin-shadow/src/ShadowXmlDomParser.ts index 98777dd8e..33c4c1f41 100644 --- a/packages/plugin-shadow/src/ShadowXmlDomParser.ts +++ b/packages/plugin-shadow/src/ShadowXmlDomParser.ts @@ -19,7 +19,10 @@ export class ShadowXmlDomParser extends AbstractParser { */ async parse(item: Element): Promise { const shadow = new ShadowNode(); - shadow.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + shadow.modifiers.append(attributes); + } const nodes = await this.engine.parse(...item.childNodes); shadow.append(...nodes); return [shadow]; diff --git a/packages/plugin-shadow/test/DomShadow.test.ts b/packages/plugin-shadow/test/DomShadow.test.ts index 87a7d186b..e44447518 100644 --- a/packages/plugin-shadow/test/DomShadow.test.ts +++ b/packages/plugin-shadow/test/DomShadow.test.ts @@ -58,9 +58,9 @@ describe('DomShadow', async () => { await editor.stop(); - expect(node.is(VElement) && node.htmlTag).to.equal('DIV'); + expect(node instanceof VElement && node.htmlTag).to.equal('DIV'); const shadow = node.firstChild(); - expect(shadow.is(ShadowNode)).to.equal(true); + expect(shadow instanceof ShadowNode).to.equal(true); expect(shadow.firstChild()).to.equal(undefined); }); it('should parse a template with which have content', async () => { @@ -80,8 +80,8 @@ describe('DomShadow', async () => { const shadow = node.firstChild(); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); - expect(section.firstChild().is(CharNode)).to.equal(true); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); + expect(section.firstChild() instanceof CharNode).to.equal(true); }); it('should parse a template with with style tag', async () => { const editor = new JWEditor(); @@ -101,10 +101,12 @@ describe('DomShadow', async () => { const shadow = node.firstChild() as ShadowNode; const style = shadow.childVNodes[0]; - expect(style.is(MetadataNode) && style.htmlTag).to.equal('STYLE'); - expect(style.is(MetadataNode) && style.contents).to.equal('* { color: red; }'); + expect(style instanceof MetadataNode && style.htmlTag).to.equal('STYLE'); + expect(style instanceof MetadataNode && style.contents).to.equal( + '* { color: red; }', + ); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); it('should parse a template with with link tag', async () => { const editor = new JWEditor(); @@ -129,23 +131,23 @@ describe('DomShadow', async () => { const shadow = node.firstChild() as ShadowNode; const link = shadow.childVNodes[0]; - expect(link.is(MetadataNode) && link.htmlTag).to.equal('LINK'); + expect(link instanceof MetadataNode && link.htmlTag).to.equal('LINK'); expect(link.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href1"}', ); const link2 = shadow.childVNodes[1]; - expect(link2.is(MetadataNode) && link2.htmlTag).to.equal('LINK'); + expect(link2 instanceof MetadataNode && link2.htmlTag).to.equal('LINK'); expect(link2.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href2"}', ); const link3 = shadow.childVNodes[2]; - expect(link3.is(MetadataNode) && link3.htmlTag).to.equal('LINK'); + expect(link3 instanceof MetadataNode && link3.htmlTag).to.equal('LINK'); expect(link3.modifiers.find(Attributes).name).to.equal( '{rel: "help", href: "/help/"}', ); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); it('should parse a template with with style and link tag', async () => { const editor = new JWEditor(); @@ -170,20 +172,22 @@ describe('DomShadow', async () => { const shadow = node.firstChild() as ShadowNode; const link = shadow.childVNodes[0]; - expect(link.is(MetadataNode) && link.htmlTag).to.equal('LINK'); + expect(link instanceof MetadataNode && link.htmlTag).to.equal('LINK'); expect(link.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href1"}', ); const style = shadow.childVNodes[1]; - expect(style.is(MetadataNode) && style.htmlTag).to.equal('STYLE'); - expect(style.is(MetadataNode) && style.contents).to.equal('* { color: red; }'); + expect(style instanceof MetadataNode && style.htmlTag).to.equal('STYLE'); + expect(style instanceof MetadataNode && style.contents).to.equal( + '* { color: red; }', + ); const link2 = shadow.childVNodes[2]; - expect(link2.is(MetadataNode) && link2.htmlTag).to.equal('LINK'); + expect(link2 instanceof MetadataNode && link2.htmlTag).to.equal('LINK'); expect(link2.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href2"}', ); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); }); describe('parse dom/html', async () => { @@ -204,11 +208,11 @@ describe('DomShadow', async () => { await editor.stop(); - expect(node.is(VElement) && node.htmlTag).to.equal('DIV'); + expect(node instanceof VElement && node.htmlTag).to.equal('DIV'); const article = node.firstChild(); - expect(article.is(VElement) && article.htmlTag).to.equal('ARTICLE'); + expect(article instanceof VElement && article.htmlTag).to.equal('ARTICLE'); const shadow = article.firstChild(); - expect(shadow.is(ShadowNode)).to.equal(true); + expect(shadow instanceof ShadowNode).to.equal(true); expect(shadow.firstChild()).to.equal(undefined); }); it('should parse a HtmlDocument with shadow container ', async () => { @@ -232,10 +236,10 @@ describe('DomShadow', async () => { await editor.stop(); const shadow = node.firstChild(); - expect(shadow.is(ShadowNode)).to.equal(true); + expect(shadow instanceof ShadowNode).to.equal(true); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); - expect(section.firstChild().is(CharNode)).to.equal(true); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); + expect(section.firstChild() instanceof CharNode).to.equal(true); }); it('should parse a HtmlDocument with shadow container which have content', async () => { const editor = new JWEditor(); @@ -258,10 +262,10 @@ describe('DomShadow', async () => { await editor.stop(); const shadow = node.firstChild().firstChild(); - expect(shadow.is(ShadowNode)).to.equal(true); + expect(shadow instanceof ShadowNode).to.equal(true); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); - expect(section.firstChild().is(CharNode)).to.equal(true); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); + expect(section.firstChild() instanceof CharNode).to.equal(true); }); it('should parse a HtmlDocument with shadow container with style tag', async () => { const editor = new JWEditor(); @@ -288,10 +292,12 @@ describe('DomShadow', async () => { const shadow = node.firstChild().firstChild() as ShadowNode; const style = shadow.childVNodes[0]; - expect(style.is(MetadataNode) && style.htmlTag).to.equal('STYLE'); - expect(style.is(MetadataNode) && style.contents).to.equal('* { color: red; }'); + expect(style instanceof MetadataNode && style.htmlTag).to.equal('STYLE'); + expect(style instanceof MetadataNode && style.contents).to.equal( + '* { color: red; }', + ); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); it('should parse a HtmlDocument with shadow container with link tag', async () => { const editor = new JWEditor(); @@ -327,22 +333,22 @@ describe('DomShadow', async () => { const shadow = node.firstChild().firstChild() as ShadowNode; const link = shadow.childVNodes[0]; - expect(link.is(MetadataNode) && link.htmlTag).to.equal('LINK'); + expect(link instanceof MetadataNode && link.htmlTag).to.equal('LINK'); expect(link.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href1"}', ); const link2 = shadow.childVNodes[1]; - expect(link2.is(MetadataNode) && link2.htmlTag).to.equal('LINK'); + expect(link2 instanceof MetadataNode && link2.htmlTag).to.equal('LINK'); expect(link2.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href2"}', ); const link3 = shadow.childVNodes[2]; - expect(link3.is(MetadataNode) && link3.htmlTag).to.equal('LINK'); + expect(link3 instanceof MetadataNode && link3.htmlTag).to.equal('LINK'); expect(link3.modifiers.find(Attributes).name).to.equal( '{rel: "help", href: "/help/"}', ); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); it('should parse a HtmlDocument with shadow container with style and link tag', async () => { const editor = new JWEditor(); @@ -377,20 +383,22 @@ describe('DomShadow', async () => { const shadow = node.firstChild().firstChild() as ShadowNode; const link = shadow.childVNodes[0]; - expect(link.is(MetadataNode) && link.htmlTag).to.equal('LINK'); + expect(link instanceof MetadataNode && link.htmlTag).to.equal('LINK'); expect(link.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href1"}', ); const style = shadow.childVNodes[1]; - expect(style.is(MetadataNode) && style.htmlTag).to.equal('STYLE'); - expect(style.is(MetadataNode) && style.contents).to.equal('* { color: red; }'); + expect(style instanceof MetadataNode && style.htmlTag).to.equal('STYLE'); + expect(style instanceof MetadataNode && style.contents).to.equal( + '* { color: red; }', + ); const link2 = shadow.childVNodes[2]; - expect(link2.is(MetadataNode) && link2.htmlTag).to.equal('LINK'); + expect(link2 instanceof MetadataNode && link2.htmlTag).to.equal('LINK'); expect(link2.modifiers.find(Attributes).name).to.equal( '{rel: "stylesheet", href: "#href2"}', ); const section = shadow.firstChild(); - expect(section.is(VElement) && section.htmlTag).to.equal('SECTION'); + expect(section instanceof VElement && section.htmlTag).to.equal('SECTION'); }); }); }); @@ -535,8 +543,16 @@ describe('DomShadow', async () => { await editor.start(); commandNames = []; const execCommand = editor.execCommand; - editor.execCommand = async (commandName: string, params: object): Promise => { - commandNames.push(commandName); + editor.execCommand = async ( + commandName: string | (() => Promise | void), + params?: object, + ): Promise => { + if (typeof commandName === 'function') { + commandNames.push('@custom'); + await commandName(); + } else { + commandNames.push(commandName); + } return execCommand.call(editor, commandName, params); }; domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; @@ -1148,8 +1164,16 @@ describe('DomShadow', async () => { await editor.start(); const commandNames = []; const execCommand = editor.execCommand; - editor.execCommand = async (commandName: string, params: object): Promise => { - commandNames.push(commandName); + editor.execCommand = async ( + commandName: string | (() => Promise | void), + params?: object, + ): Promise => { + if (typeof commandName === 'function') { + commandNames.push('@custom'); + await commandName(); + } else { + commandNames.push(commandName); + } return execCommand.call(editor, commandName, params); }; const domEngine = editor.plugins.get(Layout).engines.dom as DomLayoutEngine; diff --git a/packages/plugin-span/src/SpanXmlDomParser.ts b/packages/plugin-span/src/SpanXmlDomParser.ts index 11c8255d1..6a196d0a2 100644 --- a/packages/plugin-span/src/SpanXmlDomParser.ts +++ b/packages/plugin-span/src/SpanXmlDomParser.ts @@ -16,7 +16,10 @@ export class SpanXmlDomParser extends FormatXmlDomParser { */ async parse(item: Element): Promise { const span = new SpanFormat(nodeName(item) as 'SPAN' | 'FONT'); - span.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + span.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); // Handle empty spans. if (!children.length) { diff --git a/packages/plugin-subscript/src/SubscriptXmlDomParser.ts b/packages/plugin-subscript/src/SubscriptXmlDomParser.ts index a07eaea6f..5a9249349 100644 --- a/packages/plugin-subscript/src/SubscriptXmlDomParser.ts +++ b/packages/plugin-subscript/src/SubscriptXmlDomParser.ts @@ -15,7 +15,10 @@ export class SubscriptXmlDomParser extends FormatXmlDomParser { */ async parse(item: Element): Promise { const subscript = new SubscriptFormat(); - subscript.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + subscript.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); this.applyFormat(subscript, children); diff --git a/packages/plugin-superscript/src/SuperscriptXmlDomParser.ts b/packages/plugin-superscript/src/SuperscriptXmlDomParser.ts index ffd58f69b..3c0c52382 100644 --- a/packages/plugin-superscript/src/SuperscriptXmlDomParser.ts +++ b/packages/plugin-superscript/src/SuperscriptXmlDomParser.ts @@ -15,7 +15,10 @@ export class SuperscriptXmlDomParser extends FormatXmlDomParser { */ async parse(item: Element): Promise { const superscript = new SuperscriptFormat(); - superscript.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + superscript.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); this.applyFormat(superscript, children); diff --git a/packages/plugin-table/src/TableCellDomObjectRenderer.ts b/packages/plugin-table/src/TableCellDomObjectRenderer.ts index 87929d491..73acf5711 100644 --- a/packages/plugin-table/src/TableCellDomObjectRenderer.ts +++ b/packages/plugin-table/src/TableCellDomObjectRenderer.ts @@ -22,7 +22,6 @@ export class TableCellDomObjectRenderer extends NodeRenderer { attributes: {}, }; - // Render attributes. // Colspan and rowspan are handled differently from other attributes: // they are automatically calculated in function of the cell's managed // cells. Render them here. If their value is 1 or less, they are diff --git a/packages/plugin-table/src/TableCellNode.ts b/packages/plugin-table/src/TableCellNode.ts index abc7df9b1..775f8e703 100644 --- a/packages/plugin-table/src/TableCellNode.ts +++ b/packages/plugin-table/src/TableCellNode.ts @@ -7,13 +7,14 @@ import { AbstractNodeParams } from '../../core/src/VNodes/AbstractNode'; export interface TableCellNodeParams extends AbstractNodeParams { header: boolean; } +import { VersionableSet } from '../../core/src/Memory/VersionableSet'; export class TableCellNode extends ContainerNode { breakable = false; header: boolean; // Only the `managerCell` setter should modify the following private keys. __managerCell: TableCellNode; - __managedCells = new Set(); + __managedCells = new VersionableSet(); constructor(params?: TableCellNodeParams) { super(params); @@ -133,7 +134,7 @@ export class TableCellNode extends ContainerNode { mergeWith(newManager: VNode): void { const thisTable = this.ancestor(TableNode); const otherTable = newManager.ancestor(TableNode); - if (!newManager.is(TableCellNode) || thisTable !== otherTable) return; + if (!(newManager instanceof TableCellNode) || thisTable !== otherTable) return; this.__managerCell = newManager; newManager.manage(this); diff --git a/packages/plugin-table/src/TableCellXmlDomParser.ts b/packages/plugin-table/src/TableCellXmlDomParser.ts index 644bc0ad3..fd6f2466c 100644 --- a/packages/plugin-table/src/TableCellXmlDomParser.ts +++ b/packages/plugin-table/src/TableCellXmlDomParser.ts @@ -19,7 +19,10 @@ export class TableCellXmlDomParser extends AbstractParser { */ async parse(item: HTMLTableCellElement): Promise { const cell = new TableCellNode({ header: nodeName(item) === 'TH' }); - cell.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + cell.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); cell.append(...children); return [cell]; diff --git a/packages/plugin-table/src/TableDomObjectRenderer.ts b/packages/plugin-table/src/TableDomObjectRenderer.ts index fb07c5bb9..2218afe89 100644 --- a/packages/plugin-table/src/TableDomObjectRenderer.ts +++ b/packages/plugin-table/src/TableDomObjectRenderer.ts @@ -6,6 +6,7 @@ import { DomObject, } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; import { TableSectionAttributes } from './TableRowXmlDomParser'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class TableDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; @@ -15,7 +16,7 @@ export class TableDomObjectRenderer extends NodeRenderer { /** * Render the TableNode along with its contents (TableRowNodes). */ - async render(table: TableNode): Promise { + async render(table: TableNode, worker: RenderingEngineWorker): Promise { const objectTable: DomObject = { tag: 'TABLE', children: [], @@ -30,11 +31,11 @@ export class TableDomObjectRenderer extends NodeRenderer { }; for (const child of table.children()) { - if (child.is(TableRowNode)) { + if (child instanceof TableRowNode) { // If the child is a row, append it to its containing section. const tableSection = child.header ? objectHead : objectBody; tableSection.children.push(child); - this.engine.renderAttributes(TableSectionAttributes, child, tableSection); + this.engine.renderAttributes(TableSectionAttributes, child, tableSection, worker); if (!objectTable.children.includes(tableSection)) { objectTable.children.push(tableSection); } diff --git a/packages/plugin-table/src/TableRowXmlDomParser.ts b/packages/plugin-table/src/TableRowXmlDomParser.ts index 2a0f96069..737aa0df9 100644 --- a/packages/plugin-table/src/TableRowXmlDomParser.ts +++ b/packages/plugin-table/src/TableRowXmlDomParser.ts @@ -26,7 +26,10 @@ export class TableRowXmlDomParser extends AbstractParser { return this.parseTableSection(item); } else if (nodeName(item) === 'TR') { const row = new TableRowNode(); - row.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + row.modifiers.append(attributes); + } const cells = await this.engine.parse(...item.childNodes); row.append(...cells); return [row]; @@ -48,17 +51,17 @@ export class TableRowXmlDomParser extends AbstractParser { } // Parse the or 's modifiers. - const containerModifiers = new Modifiers(this.engine.parseAttributes(tableSection)); + const attributes = this.engine.parseAttributes(tableSection); // Apply the attributes, style and `header` property of the container to // each row. const name = nodeName(tableSection); for (const parsedNode of parsedNodes) { - if (parsedNode.is(TableRowNode)) { + if (parsedNode instanceof TableRowNode) { parsedNode.header = name === 'THEAD'; parsedNode.modifiers.replace( TableSectionAttributes, - new TableSectionAttributes(containerModifiers.get(Attributes)), + new TableSectionAttributes(attributes), ); } } diff --git a/packages/plugin-table/src/TableXmlDomParser.ts b/packages/plugin-table/src/TableXmlDomParser.ts index 12de9bb87..6f400ca7e 100644 --- a/packages/plugin-table/src/TableXmlDomParser.ts +++ b/packages/plugin-table/src/TableXmlDomParser.ts @@ -22,14 +22,17 @@ export class TableXmlDomParser extends AbstractParser { async parse(item: HTMLTableElement): Promise { // Parse the table itself and its attributes. const table = new TableNode(); - table.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + table.modifiers.append(attributes); + } // Parse the contents of the table. const children = await this.engine.parse(...item.childNodes); // Build the grid. const dimensions = this._getTableDimensions(item); - const parsedRows = children.filter(row => row.is(TableRowNode)) as TableRowNode[]; + const parsedRows = children.filter(row => row instanceof TableRowNode) as TableRowNode[]; const grid = this._createTableGrid(dimensions, parsedRows); // Append the cells to the rows. @@ -48,7 +51,7 @@ export class TableXmlDomParser extends AbstractParser { let rowIndex = 0; for (let childIndex = 0; childIndex < children.length; childIndex += 1) { const child = children[childIndex]; - if (child.is(TableRowNode)) { + if (child instanceof TableRowNode) { const row = rows[rowIndex]; table.append(row); rowIndex += 1; diff --git a/packages/plugin-table/test/Table.test.ts b/packages/plugin-table/test/Table.test.ts index 2fbbdc233..efb69d5a8 100644 --- a/packages/plugin-table/test/Table.test.ts +++ b/packages/plugin-table/test/Table.test.ts @@ -21,6 +21,7 @@ import { TableCellNode } from '../src/TableCellNode'; import { Layout } from '../../plugin-layout/src/Layout'; import { TableSectionAttributes } from '../src/TableRowXmlDomParser'; import { Attributes } from '../../plugin-xml/src/Attributes'; +import { CharNode } from '../../plugin-char/src/CharNode'; let element: Element; describePlugin(Table, testEditor => { @@ -3301,19 +3302,28 @@ describePlugin(Table, testEditor => { const table = editable.firstChild() as TableNode; await editor.execCommand('addRowBelow'); const row10 = table.children(TableRowNode)[10]; - table.addRowBelow(row10.children(TableCellNode)[0]); - const insertedRow = table.children(TableRowNode)[11]; - const insertedCells = insertedRow.children(TableCellNode); + const row11 = table.children(TableRowNode)[11]; + + await editor.execCommand(async () => { + const td = row11.children(TableCellNode)[0]; + const b = new CharNode({ char: 'b' }); + td.append(b); + editor.selection.setAt(b); + }); + + await editor.execCommand('addRowBelow'); // Test the row expect(table.children(TableRowNode)[10]).to.equal( row10, "10th row hasn't changed", ); + expect(row11).to.not.equal(row10, '11th row is a new row'); expect(table.children(TableRowNode)[11]).to.not.equal( row10, '11th row is a new row', ); + const insertedRow = table.children(TableRowNode)[11]; expect(insertedRow.header).to.equal(false, 'new row is not a header row'); expect(insertedRow.modifiers.find(Attributes)?.style.cssText).to.equal( undefined, @@ -3321,6 +3331,7 @@ describePlugin(Table, testEditor => { ); // Test individual cells + const insertedCells = insertedRow.children(TableCellNode); testActive(insertedCells, [true, false, true, true]); testHeader(insertedCells, [false, false, false, false]); testColspan(insertedCells, [2, 1, 1, 1]); @@ -3384,9 +3395,14 @@ describePlugin(Table, testEditor => { '(9, 3)', '', '', - '[](10, 2)', + '(10, 2)', '(10, 3)', '', + '', + '[]b', + '
    ', + '
    ', + '', '', '
    ', '
    ', diff --git a/packages/plugin-template/src/Template.ts b/packages/plugin-template/src/Template.ts index 07b9bc07d..32400bd6b 100644 --- a/packages/plugin-template/src/Template.ts +++ b/packages/plugin-template/src/Template.ts @@ -127,8 +127,8 @@ export class Template extends JWPlugi const filledZone = new Set(); for (const templateName of templateToSelect) { const config = templateConfigurations[templateName]; - await layout.clear(config.zoneId); if (!filledZone.has(config.thumbnailZoneId)) { + await layout.clear(config.zoneId); filledZone.add(config.thumbnailZoneId); await layout.prepend( 'TemplateThumbnailSelector-' + config.thumbnailZoneId, diff --git a/packages/plugin-textarea/src/TextareaDomObjectRenderer.ts b/packages/plugin-textarea/src/TextareaDomObjectRenderer.ts index 2d8335fa4..2ec31d48a 100644 --- a/packages/plugin-textarea/src/TextareaDomObjectRenderer.ts +++ b/packages/plugin-textarea/src/TextareaDomObjectRenderer.ts @@ -3,7 +3,6 @@ import { DomObjectRenderingEngine, DomObject, } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; -import { Attributes } from '../../plugin-xml/src/Attributes'; import { TextareaNode } from './TextareaNode'; import { DomObjectText } from '../../plugin-renderer-dom-object/src/DomObjectRenderingEngine'; @@ -21,7 +20,6 @@ export class TextareaDomObjectRenderer extends NodeRenderer { tag: 'TEXTAREA', children: [text], }; - this.engine.renderAttributes(Attributes, node, textarea); return textarea; } } diff --git a/packages/plugin-textarea/src/TextareaXmlDomParser.ts b/packages/plugin-textarea/src/TextareaXmlDomParser.ts index 87640bada..7c2d4e586 100644 --- a/packages/plugin-textarea/src/TextareaXmlDomParser.ts +++ b/packages/plugin-textarea/src/TextareaXmlDomParser.ts @@ -15,7 +15,10 @@ export class TextareaXmlDomParser extends AbstractParser { */ async parse(item: HTMLTextAreaElement): Promise { const textarea = new TextareaNode({ value: item.value }); - textarea.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + textarea.modifiers.append(attributes); + } return [textarea]; } } diff --git a/packages/plugin-theme/src/Theme.ts b/packages/plugin-theme/src/Theme.ts index b31b90fd6..f172c7898 100644 --- a/packages/plugin-theme/src/Theme.ts +++ b/packages/plugin-theme/src/Theme.ts @@ -11,7 +11,6 @@ import { ThemeNode } from './ThemeNode'; import { ZoneNode } from '../../plugin-layout/src/ZoneNode'; import { ActionableGroupNode } from '../../plugin-layout/src/ActionableGroupNode'; import { ActionableNode } from '../../plugin-layout/src/ActionableNode'; -import { DomLayoutEngine } from '../../plugin-dom-layout/src/DomLayoutEngine'; import { VElement } from '../../core/src/VNodes/VElement'; interface ThemeComponent extends ComponentDefinition { @@ -103,10 +102,6 @@ export class Theme extends JWPlugin { const ancestor = this.editor.selection.anchor.ancestor(ThemeNode); if (ancestor) { ancestor.themeName = params.theme; - - // TODO: remove this redraw when memory send the nodes to redraw into domLayout - const domEngine = this.editor.plugins.get(Layout).engines.dom as DomLayoutEngine; - await domEngine.redraw(ancestor); } } } diff --git a/packages/plugin-theme/src/ThemeDomObjectRenderer.ts b/packages/plugin-theme/src/ThemeDomObjectRenderer.ts index cf8122f7e..999c0f0ba 100644 --- a/packages/plugin-theme/src/ThemeDomObjectRenderer.ts +++ b/packages/plugin-theme/src/ThemeDomObjectRenderer.ts @@ -5,7 +5,7 @@ import { import { NodeRenderer } from '../../plugin-renderer/src/NodeRenderer'; import { ThemeNode } from './ThemeNode'; import { Theme } from './Theme'; -import { AbstractNode } from '../../core/src/VNodes/AbstractNode'; +import { RenderingEngineWorker } from '../../plugin-renderer/src/RenderingEngineCache'; export class ThemeDomObjectRenderer extends NodeRenderer { static id = DomObjectRenderingEngine.id; @@ -16,14 +16,19 @@ export class ThemeDomObjectRenderer extends NodeRenderer { const themePlugin = this.engine.editor.plugins.get(Theme); const component = themePlugin.themes[themeNode.themeName]; const nodes = await component.render(this.engine.editor); - const domObjects: DomObject[] = await this.engine.render(nodes); + const cache = await this.engine.render(nodes); + const domObjects = nodes.map(node => cache.renderings.get(node)); for (const domObject of domObjects) { - await this._resolvePlaceholder(themeNode, domObject); + await this._resolvePlaceholder(themeNode, domObject, cache.worker); } - return this._removeRef({ children: domObjects }); + return { children: domObjects }; } - private async _resolvePlaceholder(theme: ThemeNode, domObject: DomObject): Promise { - await this.engine.resolveChildren(domObject); + private async _resolvePlaceholder( + theme: ThemeNode, + domObject: DomObject, + worker: RenderingEngineWorker, + ): Promise { + await this.engine.resolveChildren(domObject, worker); let placeholderFound = false; const domObjects: DomObject[] = [domObject]; for (const domObject of domObjects) { @@ -56,20 +61,4 @@ export class ThemeDomObjectRenderer extends NodeRenderer { } } } - private _removeRef(domObject: DomObject): DomObject { - const domObjects = [domObject]; - for (const domObject of domObjects) { - if ('children' in domObject) { - for (let index = 0; index < domObject.children.length; index++) { - const child = domObject.children[index]; - if (!(child instanceof AbstractNode)) { - domObject.children[index] = Object.create(child); - // Recursively apply on children in one stack. - domObjects.push(child); - } - } - } - } - return Object.create(domObject); - } } diff --git a/packages/plugin-theme/src/ThemeXmlDomParser.ts b/packages/plugin-theme/src/ThemeXmlDomParser.ts index deb22928d..3dde77811 100644 --- a/packages/plugin-theme/src/ThemeXmlDomParser.ts +++ b/packages/plugin-theme/src/ThemeXmlDomParser.ts @@ -3,7 +3,6 @@ import { AbstractParser } from '../../plugin-parser/src/AbstractParser'; import { XmlDomParsingEngine } from '../../plugin-xml/src/XmlDomParsingEngine'; import { ThemeNode } from './ThemeNode'; import { nodeName } from '../../utils/src/utils'; -import { Attributes } from '../../plugin-xml/src/Attributes'; export class ThemeXmlDomParser extends AbstractParser { static id = XmlDomParsingEngine.id; @@ -20,8 +19,11 @@ export class ThemeXmlDomParser extends AbstractParser { */ async parse(item: Element): Promise { const theme = new ThemeNode({ theme: item.getAttribute('name') }); - theme.modifiers.append(this.engine.parseAttributes(item)); - theme.modifiers.find(Attributes)?.remove('name'); + const attributes = this.engine.parseAttributes(item); + attributes.remove('name'); + if (attributes.length) { + theme.modifiers.append(attributes); + } const nodes = await this.engine.parse(...item.childNodes); theme.append(...nodes); return [theme]; diff --git a/packages/plugin-underline/src/UnderlineXmlDomParser.ts b/packages/plugin-underline/src/UnderlineXmlDomParser.ts index 193fe74b4..404dd22a7 100644 --- a/packages/plugin-underline/src/UnderlineXmlDomParser.ts +++ b/packages/plugin-underline/src/UnderlineXmlDomParser.ts @@ -15,7 +15,10 @@ export class UnderlineXmlDomParser extends FormatXmlDomParser { */ async parse(item: Element): Promise { const underline = new UnderlineFormat(); - underline.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + underline.modifiers.append(attributes); + } const children = await this.engine.parse(...item.childNodes); this.applyFormat(underline, children); diff --git a/packages/plugin-xml/src/Attributes.ts b/packages/plugin-xml/src/Attributes.ts index c82daaa27..518067b9b 100644 --- a/packages/plugin-xml/src/Attributes.ts +++ b/packages/plugin-xml/src/Attributes.ts @@ -1,9 +1,10 @@ import { Modifier } from '../../core/src/Modifier'; import { CssStyle } from './CssStyle'; import { ClassList } from './ClassList'; +import { makeVersionable } from '../../core/src/Memory/Versionable'; export class Attributes extends Modifier { - private _record: Record = {}; + private _record: Record; style = new CssStyle(); // Avoid copiying FontAwesome classes on paragraph break. // TODO : need to be improved to better take care of color classes, etc. @@ -50,9 +51,15 @@ export class Attributes extends Modifier { */ clone(): this { const clone = new this.constructor(); - clone._record = { ...this._record }; - clone.style = this.style.clone(); - clone.classList = this.classList.clone(); + if (this._record) { + clone._record = makeVersionable({ ...this._record }); + } + if (this.style.length) { + clone.style = this.style.clone(); + } + if (this.classList.length) { + clone.classList = this.classList.clone(); + } return clone; } /** @@ -83,12 +90,14 @@ export class Attributes extends Modifier { * Return an array containing all the keys in the record. */ keys(): string[] { - const keys = Object.keys(this._record).filter(key => { - return ( - (key !== 'style' || !!this.style.length) && - (key !== 'class' || !!this.classList.length) - ); - }); + const keys = this._record + ? Object.keys(this._record).filter(key => { + return ( + (key !== 'style' || !!this.style.length) && + (key !== 'class' || !!this.classList.length) + ); + }) + : []; if (this.classList.length && !keys.includes('class')) { // The node was not parsed with a class attribute, add it in place. // Use `get` for its value but record its position in the record. @@ -116,11 +125,11 @@ export class Attributes extends Modifier { get(name: string): string { name = name.toLowerCase(); if (name === 'style') { - return this.style.cssText; + return this.style?.cssText; } else if (name === 'class') { - return this.classList.className; + return this.classList?.className; } else { - return this._record[name]; + return this._record?.[name]; } } /** @@ -131,8 +140,15 @@ export class Attributes extends Modifier { */ set(name: string, value: string): void { name = name.toLowerCase(); + if (!this._record) { + this._record = makeVersionable({}); + } if (name === 'style') { - this.style.reset(value); + if (this.style) { + this.style.reset(value); + } else { + this.style = new CssStyle(); + } // Use `get` for its value but record its position in the record. this._record.style = null; } else if (name === 'class') { @@ -155,13 +171,13 @@ export class Attributes extends Modifier { this.style.clear(); } else if (name === 'class') { this.classList.clear(); - } else { + } else if (this._record) { delete this._record[name]; } } } clear(): void { - this._record = {}; + delete this._record; this.style.clear(); this.classList.clear(); } diff --git a/packages/plugin-xml/src/ClassList.ts b/packages/plugin-xml/src/ClassList.ts index 217b1bdbc..be8d47621 100644 --- a/packages/plugin-xml/src/ClassList.ts +++ b/packages/plugin-xml/src/ClassList.ts @@ -1,6 +1,10 @@ -export class ClassList { - private _classList = new Set(); +import { VersionableObject } from '../../core/src/Memory/VersionableObject'; +import { VersionableSet } from '../../core/src/Memory/VersionableSet'; + +export class ClassList extends VersionableObject { + private _classList: Set; constructor(...classList: string[]) { + super(); for (const className of classList) { this.add(className); } @@ -14,13 +18,13 @@ export class ClassList { * Return the number of classes in the set. */ get length(): number { - return this._classList.size; + return this._classList?.size || 0; } /** * Return a textual representation of the set. */ get className(): string { - if (!this._classList.size) return; + if (!this._classList?.size) return; return Array.from(this._classList).join(' '); } /** @@ -52,7 +56,9 @@ export class ClassList { */ clone(): ClassList { const clone = new ClassList(); - clone._classList = new Set(this._classList); + if (this._classList?.size) { + clone._classList = new VersionableSet(this._classList); + } return clone; } @@ -66,13 +72,13 @@ export class ClassList { * @param name */ has(name: string): boolean { - return this._classList.has(name); + return this._classList?.has(name) || false; } /** * Return an array containing all the items in the list. */ items(): string[] { - return Array.from(this._classList); + return this._classList ? Array.from(this._classList) : []; } /** * Add the given class(es) to the set. @@ -80,6 +86,9 @@ export class ClassList { * @param classNames */ add(...classNames: string[]): void { + if (!this._classList) { + this._classList = new VersionableSet(); + } for (const className of classNames) { if (className) { const classes = this.parseClassName(className); @@ -95,6 +104,7 @@ export class ClassList { * @param classNames */ remove(...classNames: string[]): void { + if (!this._classList?.size) return; for (const className of classNames) { if (className) { const classes = this.parseClassName(className); @@ -108,7 +118,7 @@ export class ClassList { * Clear the set of all its classes. */ clear(): void { - this._classList = new Set(); + delete this._classList; } /** * Reinitialize the set with a new set of classes (empty if no argument is @@ -117,7 +127,7 @@ export class ClassList { * @param classList */ reset(...classList: string[]): void { - this._classList.clear(); + delete this._classList; for (const className of classList) { this.add(className); } @@ -129,6 +139,9 @@ export class ClassList { * @param classes */ toggle(...classes: string[]): void { + if (!this._classList) { + this._classList = new VersionableSet(); + } for (const className of classes) { if (className) { const parsed = this.parseClassName(className); diff --git a/packages/plugin-xml/src/CssStyle.ts b/packages/plugin-xml/src/CssStyle.ts index a9767572e..7679007b4 100644 --- a/packages/plugin-xml/src/CssStyle.ts +++ b/packages/plugin-xml/src/CssStyle.ts @@ -1,6 +1,10 @@ -export class CssStyle { - private _style: Record = {}; +import { VersionableObject } from '../../core/src/Memory/VersionableObject'; +import { makeVersionable } from '../../core/src/Memory/Versionable'; + +export class CssStyle extends VersionableObject { + private _style: Record; constructor(style?: string | Record) { + super(); if (style) { this.reset(style); } @@ -14,12 +18,15 @@ export class CssStyle { * Return the number of styles in the set. */ get length(): number { - return Object.keys(this._style).length; + return this._style ? Object.keys(this._style).length : 0; } /** * Return a textual representation of the CSS declaration block. */ get cssText(): string { + if (!this._style) { + return; + } const keys = Object.keys(this._style); if (!Object.keys(this._style).length) return; const valueRepr = []; @@ -66,7 +73,9 @@ export class CssStyle { */ clone(): CssStyle { const clone = new CssStyle(); - clone._style = { ...this._style }; + if (this._style) { + clone._style = makeVersionable({ ...this._style }); + } return clone; } /** @@ -86,19 +95,19 @@ export class CssStyle { * @param key */ has(key: string): boolean { - return !!this._style[key]; + return !!this._style?.[key]; } /** * Return an array containing all the keys in the record. */ keys(): string[] { - return Object.keys(this._style); + return this._style ? Object.keys(this._style) : []; } /** * Return an array containing all the values in the record. */ values(): string[] { - return Object.values(this._style); + return this._style ? Object.values(this._style) : []; } /** * Return the record matching the given name. @@ -106,7 +115,7 @@ export class CssStyle { * @param name */ get(name: string): string { - return this._style[name]; + return this._style?.[name]; } /** * Set the record with the given name to the given value. @@ -115,6 +124,9 @@ export class CssStyle { * @param value */ set(name: string, value: string): void { + if (!this._style) { + this._style = makeVersionable({}); + } this._style[name] = value; } /** @@ -123,15 +135,17 @@ export class CssStyle { * @param name */ remove(...names: string[]): void { - for (const name of names) { - delete this._style[name]; + if (this._style) { + for (const name of names) { + delete this._style[name]; + } } } /** * Clear the record of all its styles. */ clear(): void { - this._style = {}; + delete this._style; } /** * Reinitialize the record with a new record of styles (empty if no argument @@ -139,11 +153,17 @@ export class CssStyle { * * @param style */ - reset(style: Record | string = {}): void { + reset(style: Record | string = ''): void { if (typeof style === 'object') { - this._style = style; + if (Object.keys(style).length) { + this._style = makeVersionable(style); + } else { + delete this._style; + } + } else if (style.length) { + this._style = makeVersionable(this.parseCssText(style)); } else { - this._style = this.parseCssText(style); + delete this._style; } } } diff --git a/packages/plugin-xml/src/DefaultXmlDomParser.ts b/packages/plugin-xml/src/DefaultXmlDomParser.ts index 57779d599..fe9b04a14 100644 --- a/packages/plugin-xml/src/DefaultXmlDomParser.ts +++ b/packages/plugin-xml/src/DefaultXmlDomParser.ts @@ -14,7 +14,10 @@ export class DefaultXmlDomParser extends AbstractParser { // but we don't break it either. const element = new VElement({ htmlTag: nodeName(item) }); if (item instanceof Element) { - element.modifiers.append(this.engine.parseAttributes(item)); + const attributes = this.engine.parseAttributes(item); + if (attributes.length) { + element.modifiers.append(attributes); + } } const nodes = await this.engine.parse(...item.childNodes); element.append(...nodes); diff --git a/packages/utils/src/EventMixin.ts b/packages/utils/src/EventMixin.ts index 0dbc1d93f..d6bf598ea 100644 --- a/packages/utils/src/EventMixin.ts +++ b/packages/utils/src/EventMixin.ts @@ -1,7 +1,12 @@ +import { VersionableObject } from '../../core/src/Memory/VersionableObject'; +import { VersionableArray } from '../../core/src/Memory/VersionableArray'; +import { makeVersionable } from '../../core/src/Memory/Versionable'; +import { VersionableSet } from '../../core/src/Memory/VersionableSet'; + /** * Abstract class to add event mechanism. */ -export class EventMixin { +export class EventMixin extends VersionableObject { _eventCallbacks: Record; _callbackWorking: Set; @@ -13,10 +18,10 @@ export class EventMixin { */ on(eventName: string, callback: Function): void { if (!this._eventCallbacks) { - this._eventCallbacks = {}; + this._eventCallbacks = makeVersionable({}); } if (!this._eventCallbacks[eventName]) { - this._eventCallbacks[eventName] = []; + this._eventCallbacks[eventName] = new VersionableArray(); } this._eventCallbacks[eventName].push(callback); } @@ -30,7 +35,7 @@ export class EventMixin { trigger(eventName: string, args?: A): void { if (this._eventCallbacks?.[eventName]) { if (!this._callbackWorking) { - this._callbackWorking = new Set(); + this._callbackWorking = new VersionableSet(); } for (const callback of this._eventCallbacks[eventName]) { if (!this._callbackWorking.has(callback)) { diff --git a/packages/utils/src/configuration.ts b/packages/utils/src/configuration.ts index 28e0f2a02..26313eb7f 100644 --- a/packages/utils/src/configuration.ts +++ b/packages/utils/src/configuration.ts @@ -94,7 +94,7 @@ export async function parseElement(editor: JWEditor, element: HTMLElement): Prom if (forceAfter) { return [reference, RelativePosition.AFTER]; } - if (forcePrepend && reference.is(ContainerNode)) { + if (forcePrepend && reference instanceof ContainerNode) { return [reference, RelativePosition.INSIDE]; }