From 559a9b5556f6c2f9567dc811588e6259a531db6b Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Sat, 5 Oct 2024 08:02:06 +0300 Subject: [PATCH 1/6] Fix sorter performance (#6187) --- .../core/src/commands/view/SelectPosition.ts | 9 +- packages/core/src/utils/Droppable.ts | 7 - .../src/utils/sorter/BaseComponentNode.ts | 41 ++- .../utils/sorter/CanvasNewComponentNode.ts | 49 ++- .../core/src/utils/sorter/ComponentSorter.ts | 19 ++ packages/core/src/utils/sorter/Dimension.ts | 116 +++++++ .../utils/sorter/DropLocationDeterminer.ts | 318 +++++++++++------- .../core/src/utils/sorter/PlaceholderClass.ts | 31 +- packages/core/src/utils/sorter/RateLimiter.ts | 31 ++ .../core/src/utils/sorter/SortableTreeNode.ts | 14 +- packages/core/src/utils/sorter/Sorter.ts | 8 +- packages/core/src/utils/sorter/SorterUtils.ts | 17 +- packages/core/src/utils/sorter/types.ts | 13 +- 13 files changed, 500 insertions(+), 173 deletions(-) create mode 100644 packages/core/src/utils/sorter/Dimension.ts create mode 100644 packages/core/src/utils/sorter/RateLimiter.ts diff --git a/packages/core/src/commands/view/SelectPosition.ts b/packages/core/src/commands/view/SelectPosition.ts index 659df96b3..e308589e3 100644 --- a/packages/core/src/commands/view/SelectPosition.ts +++ b/packages/core/src/commands/view/SelectPosition.ts @@ -13,7 +13,7 @@ export default { const utils = this.em.Utils; const container = sourceElements[0].ownerDocument.body; - if (utils && !this.sorter) + if (utils) this.sorter = new utils.ComponentSorter({ em: this.em, treeClass: CanvasComponentNode, @@ -36,13 +36,6 @@ export default { }); if (opts.onStart) this.sorter.eventHandlers.legacyOnStartSort = opts.onStart; - this.em.on( - 'frame:scroll', - ((...agrs: any[]) => { - const canvasScroll = this.canvas.getCanvasView().frame === agrs[0].frame; - if (canvasScroll) this.sorter.recalculateTargetOnScroll(); - }).bind(this), - ); sourceElements && sourceElements.length > 0 && this.sorter.startSort(sourceElements.map((element) => ({ element }))); diff --git a/packages/core/src/utils/Droppable.ts b/packages/core/src/utils/Droppable.ts index 5a0fa10a0..94399aa02 100644 --- a/packages/core/src/utils/Droppable.ts +++ b/packages/core/src/utils/Droppable.ts @@ -187,13 +187,6 @@ export default class Droppable { sorter.eventHandlers.legacyOnEnd = sorterOptions.legacyOnEnd; sorter.containerContext.customTarget = sorterOptions.customTarget; } - this.em.on( - 'frame:scroll', - ((...agrs: any[]) => { - const canvasScroll = this.canvas.getCanvasView().frame === agrs[0].frame; - if (canvasScroll) sorter.recalculateTargetOnScroll(); - }).bind(this), - ); let dropModel = this.getTempDropModel(content); const el = dropModel.view?.el; const sources = el ? [{ element: el, dragSource: dragSourceOrigin }] : []; diff --git a/packages/core/src/utils/sorter/BaseComponentNode.ts b/packages/core/src/utils/sorter/BaseComponentNode.ts index 2247f09df..98bcb8779 100644 --- a/packages/core/src/utils/sorter/BaseComponentNode.ts +++ b/packages/core/src/utils/sorter/BaseComponentNode.ts @@ -8,6 +8,8 @@ import { SortableTreeNode } from './SortableTreeNode'; * Subclasses must implement the `view` and `element` methods. */ export abstract class BaseComponentNode extends SortableTreeNode { + private displayCache: Map = new Map(); + /** * Get the list of child components. * @returns {BaseComponentNode[] | null} - The list of children wrapped in @@ -19,20 +21,37 @@ export abstract class BaseComponentNode extends SortableTreeNode { /** * Get the list of displayed children, i.e., components that have a valid HTML element. + * Cached values are used to avoid recalculating the display status unnecessarily. * @returns {BaseComponentNode[] | null} - The list of displayed children wrapped in * BaseComponentNode, or null if there are no displayed children. */ private getDisplayedChildren(): BaseComponentNode[] | null { const children = this.model.components(); - const displayedChildren = children.filter((child) => { - const element = child.getEl(); - - return isDisplayed(element); - }); + const displayedChildren = children.filter((child) => this.isChildDisplayed(child)); return displayedChildren.map((comp: Component) => new (this.constructor as any)(comp)); } + /** + * Check if a child is displayed, using cached value if available. + * @param {Component} child - The child component to check. + * @returns {boolean} - Whether the child is displayed. + */ + private isChildDisplayed(child: Component): boolean { + // Check if display status is cached + if (this.displayCache.has(child)) { + return this.displayCache.get(child)!; + } + + const element = child.getEl(); + const displayed = isDisplayed(element); + + // Cache the display status + this.displayCache.set(child, displayed); + + return displayed; + } + /** * Get the parent component of this node. * @returns {BaseComponentNode | null} - The parent wrapped in BaseComponentNode, @@ -114,8 +133,7 @@ export abstract class BaseComponentNode extends SortableTreeNode { for (let i = 0; i < children.length; i++) { const child = children.at(i); - const element = child.getEl(); - const displayed = isDisplayed(element); + const displayed = this.isChildDisplayed(child); if (displayed) displayedCount++; if (displayedCount === index + 1) return i; @@ -208,8 +226,13 @@ export abstract class BaseComponentNode extends SortableTreeNode { } } -function isDisplayed(element: HTMLElement | undefined) { - if (!!!element) return false; +/** + * Function to check if an element is displayed in the DOM. + * @param {HTMLElement | undefined} element - The element to check. + * @returns {boolean} - Whether the element is displayed. + */ +function isDisplayed(element: HTMLElement | undefined): boolean { + if (!element) return false; return ( element instanceof HTMLElement && window.getComputedStyle(element).display !== 'none' && diff --git a/packages/core/src/utils/sorter/CanvasNewComponentNode.ts b/packages/core/src/utils/sorter/CanvasNewComponentNode.ts index 8447001a8..045538c97 100644 --- a/packages/core/src/utils/sorter/CanvasNewComponentNode.ts +++ b/packages/core/src/utils/sorter/CanvasNewComponentNode.ts @@ -3,23 +3,61 @@ import CanvasComponentNode from './CanvasComponentNode'; import { getSymbolMain, getSymbolTop, isSymbol, isSymbolMain } from '../../dom_components/model/SymbolUtils'; import Component from '../../dom_components/model/Component'; import { ContentElement, ContentType } from './types'; +import { isComponent } from '../mixins'; type CanMoveSource = Component | ContentType; export default class CanvasNewComponentNode extends CanvasComponentNode { + /** + * A cache of shallow editor models stored in the source node. + * This is a map where each content item is associated with its corresponding shallow wrapper model. + */ + private _cachedShallowModels: Map = new Map(); + + /** + * Ensures the shallow editor model for the given contentItem is cached in the source node. + * If not cached, retrieves it from the shallow wrapper and stores it in the cache. + * @param contentItem - The content item to retrieve or cache. + * @returns {Component | null} - The shallow wrapper model, either cached or retrieved. + */ + cacheSrcModelForContent(contentItem: ContentElement | Component): Component | undefined { + if (isComponent(contentItem)) { + return contentItem; + } + + if (this._cachedShallowModels.has(contentItem)) { + return this._cachedShallowModels.get(contentItem)!; + } + + const wrapper = this.model.em.Components.getShallowWrapper(); + const srcModel = wrapper?.append(contentItem)[0]; + // Replace getEl as the element would be removed in the shallow wrapper after 100ms + const el = srcModel?.getEl(); + srcModel!.getEl = () => el; + + if (srcModel) { + this._cachedShallowModels.set(contentItem, srcModel); + } + + return srcModel; + } + canMove(source: CanvasNewComponentNode, index: number): boolean { const realIndex = this.getRealIndex(index); const { model: symbolModel, content, dragDef } = source._dragSource; const canMoveSymbol = !symbolModel || !this.isSourceSameSymbol(symbolModel); const sourceContent: CanMoveSource = (isFunction(content) ? dragDef : content) || source.model; + if (Array.isArray(sourceContent)) { return ( - sourceContent.every((contentItem, i) => this.canMoveSingleContent(contentItem, realIndex + i)) && canMoveSymbol + sourceContent.every((contentItem, i) => + this.canMoveSingleContent(source.cacheSrcModelForContent(contentItem)!, realIndex + i), + ) && canMoveSymbol ); } - return this.canMoveSingleContent(sourceContent, realIndex) && canMoveSymbol; + return this.canMoveSingleContent(source.cacheSrcModelForContent(sourceContent)!, realIndex) && canMoveSymbol; } private canMoveSingleContent(contentItem: ContentElement | Component, index: number): boolean { @@ -39,6 +77,13 @@ export default class CanvasNewComponentNode extends CanvasComponentNode { return this.addSingleChild(content, index, insertingTextableIntoText); } + /** + * Adds a single content item to the current node. + * @param {ContentType} content - The content to add. + * @param {number} index - The index where the content is to be added. + * @param {boolean} insertingTextableIntoText - Whether the operation involves textable content. + * @returns {CanvasNewComponentNode} - The newly added node. + */ private addSingleChild( content: ContentType, index: number, diff --git a/packages/core/src/utils/sorter/ComponentSorter.ts b/packages/core/src/utils/sorter/ComponentSorter.ts index dba5e9316..b71e9313c 100644 --- a/packages/core/src/utils/sorter/ComponentSorter.ts +++ b/packages/core/src/utils/sorter/ComponentSorter.ts @@ -1,3 +1,4 @@ +import { bindAll } from 'underscore'; import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; import Component from '../../dom_components/model/Component'; import EditorModel from '../../editor/model/Editor'; @@ -62,6 +63,8 @@ export default class ComponentSorter extends }, }, }); + + bindAll(this, 'handleScrollEvent'); } private onStartSort() { @@ -69,6 +72,22 @@ export default class ComponentSorter extends this.setAutoCanvasScroll(true); } + protected bindDragEventHandlers() { + this.em.on('frame:scroll', this.handleScrollEvent); + super.bindDragEventHandlers(); + } + + protected cleanupEventListeners(): void { + this.em.off('frame:scroll', this.handleScrollEvent); + super.cleanupEventListeners(); + } + + handleScrollEvent(...agrs: any[]) { + const frame = agrs?.[0]?.frame; + const canvasScroll = this.em.Canvas.getCanvasView().frame === frame; + if (canvasScroll) this.recalculateTargetOnScroll(); + } + private onMouseMove = (mouseEvent: MouseEvent) => { const insertingTextableIntoText = this.targetIsText && this.sourceNodes?.some((node) => node.isTextable()); if (insertingTextableIntoText) { diff --git a/packages/core/src/utils/sorter/Dimension.ts b/packages/core/src/utils/sorter/Dimension.ts new file mode 100644 index 000000000..0fbfcf9d3 --- /dev/null +++ b/packages/core/src/utils/sorter/Dimension.ts @@ -0,0 +1,116 @@ +import CanvasModule from '../../canvas'; +import { Placement } from './types'; + +/** + * A class representing dimensions of an element, including position, size, offsets, and other metadata. + * Provides functionality to calculate differences between current and previous dimensions and update them. + */ +export default class Dimension { + public top: number; + public left: number; + public height: number; + public width: number; + public offsets: ReturnType; + public dir?: boolean; + + /** + * Initializes the DimensionCalculator with the given initial dimensions. + * + * @param initialDimensions - The initial dimensions containing `top`, `left`, `height`, `width`, and other properties. + */ + constructor(initialDimensions: { + top: number; + left: number; + height: number; + width: number; + offsets: ReturnType; + dir?: boolean; + el?: HTMLElement; + indexEl?: number; + }) { + this.top = initialDimensions.top; + this.left = initialDimensions.left; + this.height = initialDimensions.height; + this.width = initialDimensions.width; + this.offsets = initialDimensions.offsets; + this.dir = initialDimensions.dir; + } + + /** + * Calculates the difference between the current and previous dimensions. + * If there are no previous dimensions, it will return zero differences. + * + * @returns An object containing the differences in `top` and `left` positions. + */ + public calculateDimensionDifference(dimension: Dimension): { topDifference: number; leftDifference: number } { + const topDifference = dimension.top - this.top; + const leftDifference = dimension.left - this.left; + + return { topDifference, leftDifference }; + } + + /** + * Updates the current dimensions by adding the given differences to the `top` and `left` values. + * + * @param topDifference - The difference to add to the current `top` value. + * @param leftDifference - The difference to add to the current `left` value. + */ + public adjustDimensions(difference: { topDifference: number; leftDifference: number }): Dimension { + this.top += difference.topDifference; + this.left += difference.leftDifference; + + return this; + } + + /** + * Determines the placement ('before' or 'after') based on the X and Y coordinates and center points. + * + * @param {number} mouseX X coordinate of the mouse + * @param {number} mouseY Y coordinate of the mouse + * @return {Placement} 'before' or 'after' + */ + public determinePlacement(mouseX: number, mouseY: number): Placement { + const xCenter = this.left + this.width / 2; + const yCenter = this.top + this.height / 2; + + if (this.dir) { + return mouseY < yCenter ? 'before' : 'after'; + } else { + return mouseX < xCenter ? 'before' : 'after'; + } + } + + /** + * Compares the current dimension object with another dimension to check equality. + * + * @param {Dimension} dimension - The dimension to compare against. + * @returns {boolean} True if the dimensions are equal, otherwise false. + */ + public equals(dimension: Dimension | undefined): boolean { + if (!dimension) return false; + return ( + this.top === dimension.top && + this.left === dimension.left && + this.height === dimension.height && + this.width === dimension.width && + this.dir === dimension.dir && + JSON.stringify(this.offsets) === JSON.stringify(dimension.offsets) + ); + } + + /** + * Creates a clone of the current Dimension object. + * + * @returns {Dimension} A new Dimension object with the same properties. + */ + public clone(): Dimension { + return new Dimension({ + top: this.top, + left: this.left, + height: this.height, + width: this.width, + offsets: { ...this.offsets }, // Shallow copy of offsets + dir: this.dir, + }); + } +} diff --git a/packages/core/src/utils/sorter/DropLocationDeterminer.ts b/packages/core/src/utils/sorter/DropLocationDeterminer.ts index fef8fb80c..c8cccc0ee 100644 --- a/packages/core/src/utils/sorter/DropLocationDeterminer.ts +++ b/packages/core/src/utils/sorter/DropLocationDeterminer.ts @@ -3,17 +3,11 @@ import { $, View } from '../../common'; import EditorModel from '../../editor/model/Editor'; import { isTextNode, off, on } from '../dom'; import { SortableTreeNode } from './SortableTreeNode'; -import { - Dimension, - Placement, - PositionOptions, - DragDirection, - SorterEventHandlers, - CustomTarget, - DragSource, -} from './types'; +import { Placement, PositionOptions, DragDirection, SorterEventHandlers, CustomTarget, DragSource } from './types'; import { bindAll, each } from 'underscore'; -import { matches, findPosition, offset, isInFlow } from './SorterUtils'; +import { matches, findPosition, offset, isStyleInFlow } from './SorterUtils'; +import { RateLimiter } from './RateLimiter'; +import Dimension from './Dimension'; type ContainerContext = { container: HTMLElement; @@ -35,19 +29,19 @@ interface DropLocationDeterminerOptions> * Represents the data related to the last move event during drag-and-drop sorting. * This type is discriminated by the presence or absence of a valid target node. */ -type LastMoveData = { +type lastMoveData = { /** The target node under the mouse pointer during the last move. */ - lastTargetNode?: NodeType; + targetNode?: NodeType; + /** The node under the mouse pointer during this move*/ + hoveredNode?: NodeType; /** The index where the placeholder or dragged element should be inserted. */ - lastIndex?: number; + index?: number; /** Placement relative to the target ('before' or 'after'). */ - lastPlacement?: Placement; - /** The dimensions of the target node. */ - lastTargetDimensions?: Dimension; - /** The dimensions of the child elements within the target node. */ - lastChildrenDimensions?: Dimension[]; + placement?: Placement; /** The mouse event, used if we want to move placeholder with scrolling. */ - lastMouseEvent?: MouseEvent; + mouseEvent?: MouseEvent; + + placeholderDimensions?: Dimension; }; export class DropLocationDeterminer> extends View { @@ -60,11 +54,13 @@ export class DropLocationDeterminer> ext eventHandlers: SorterEventHandlers; sourceNodes: NodeType[] = []; - lastMoveData!: LastMoveData; + lastMoveData!: lastMoveData; containerOffset = { top: 0, left: 0, }; + private moveThreshold: number = 20; + private rateLimiter: RateLimiter; // Rate limiter for onMove constructor(options: DropLocationDeterminerOptions) { super(); @@ -77,6 +73,7 @@ export class DropLocationDeterminer> ext bindAll(this, 'endDrag', 'cancelDrag', 'recalculateTargetOnScroll', 'startSort', 'onDragStart', 'onMove'); this.restLastMoveData(); + this.rateLimiter = new RateLimiter(this.moveThreshold); } /** @@ -101,24 +98,31 @@ export class DropLocationDeterminer> ext * to determine the new target. */ recalculateTargetOnScroll(): void { - const { lastTargetNode, lastMouseEvent } = this.lastMoveData; - - // recalculate dimensions when the canvas is scrolled - this.restLastMoveData(); - this.lastMoveData.lastTargetNode = lastTargetNode; + const { mouseEvent: lastMouseEvent } = this.lastMoveData; if (!lastMouseEvent) { return; } this.onMove(lastMouseEvent); - this.lastMoveData.lastMouseEvent = lastMouseEvent; } private onMove(mouseEvent: MouseEvent): void { + this.rateLimiter.updateArgs(mouseEvent); + this.rateLimiter.execute(this.handleMove.bind(this)); + } + + private handleMove(mouseEvent: MouseEvent): void { + this.adjustForScroll(); + + const { targetNode: lastTargetNode } = this.lastMoveData; this.eventHandlers.onMouseMove?.(mouseEvent); const { mouseXRelativeToContainer: mouseX, mouseYRelativeToContainer: mouseY } = this.getMousePositionRelativeToContainer(mouseEvent); const targetNode = this.getTargetNode(mouseEvent); + const targetChanged = !targetNode?.equals(lastTargetNode); + if (targetChanged) { + this.eventHandlers.onTargetChange?.(lastTargetNode, targetNode); + } if (!targetNode) { this.triggerLegacyOnMoveCallback(mouseEvent, 0); this.triggerMoveEvent(mouseX, mouseY); @@ -127,49 +131,77 @@ export class DropLocationDeterminer> ext } // Handle movement over the valid target node - const index = this.handleMovementOnTarget(targetNode, mouseX, mouseY); + const { index, placement, placeholderDimensions } = this.getDropPosition(targetNode, mouseX, mouseY); + + const placeHolderMoved = + !placeholderDimensions.equals(this.lastMoveData.placeholderDimensions) || + placement !== this.lastMoveData.placement; + if (placeHolderMoved) { + this.eventHandlers.onPlaceholderPositionChange?.(placeholderDimensions!, placement!); + } + + this.lastMoveData = { + ...this.lastMoveData, + targetNode, + mouseEvent, + index, + placement, + placeholderDimensions, + }; this.triggerMoveEvent(mouseX, mouseY); this.triggerLegacyOnMoveCallback(mouseEvent, index); - this.lastMoveData.lastMouseEvent = mouseEvent; + } + + private adjustForScroll() { + const lastTargetNode = this.lastMoveData.targetNode; + if (lastTargetNode?.element) { + const dims = this.getDim(lastTargetNode?.element); + const diff = lastTargetNode.nodeDimensions?.calculateDimensionDifference(dims); + if (diff) { + lastTargetNode.adjustDimensions(diff); + } + } + + const lastHoveredNode = this.lastMoveData.hoveredNode; + if (lastHoveredNode?.element) { + const dims = this.getDim(lastHoveredNode?.element); + const diff = lastHoveredNode.nodeDimensions?.calculateDimensionDifference(dims); + if (diff) { + lastHoveredNode.adjustDimensions(diff); + } + } } private restLastMoveData() { this.lastMoveData = { - lastTargetNode: undefined, - lastIndex: undefined, - lastPlacement: undefined, - lastTargetDimensions: undefined, - lastChildrenDimensions: undefined, - lastMouseEvent: undefined, + targetNode: undefined, + index: undefined, + placement: undefined, + mouseEvent: undefined, }; } - private triggerLegacyOnMoveCallback(mouseEvent: MouseEvent, index: number) { + private triggerLegacyOnMoveCallback(mouseEvent: MouseEvent, index?: number) { // For backward compatibility, leave it to a single node const model = this.sourceNodes[0]?.model; this.eventHandlers.legacyOnMoveClb?.({ event: mouseEvent, target: model, - parent: this.lastMoveData.lastTargetNode?.model, + parent: this.lastMoveData.targetNode?.model, index: index, }); } private triggerMoveEvent(mouseX: number, mouseY: number) { - const { - lastTargetNode: targetNode, - lastPlacement: placement, - lastIndex: index, - lastChildrenDimensions: childrenDimensions, - } = this.lastMoveData; + const { targetNode: targetNode, placement: placement, index: index } = this.lastMoveData; const legacyIndex = index ? index + (placement === 'after' ? -1 : 0) : 0; this.em.trigger('sorter:drag', { target: targetNode?.element || null, - targetModel: this.lastMoveData.lastTargetNode?.model, + targetModel: this.lastMoveData.targetNode?.model, sourceModel: this.sourceNodes[0].model, - dims: childrenDimensions || [], + dims: targetNode?.childrenDimensions || [], pos: { index: legacyIndex, indexEl: legacyIndex, @@ -184,64 +216,100 @@ export class DropLocationDeterminer> ext * Handles the movement of the dragged element over a target node. * Updates the placeholder position and triggers relevant events when necessary. * - * @param hoveredNode - The node currently being hovered over. + * @param node - The node currently being hovered over. * @param mouseX - The x-coordinate of the mouse relative to the container. * @param mouseY - The y-coordinate of the mouse relative to the container. - * @returns The index at which the placeholder should be positioned. */ - private handleMovementOnTarget(hoveredNode: NodeType, mouseX: number, mouseY: number): number { - const { lastTargetNode, lastChildrenDimensions } = this.lastMoveData; - - const targetChanged = !hoveredNode.equals(lastTargetNode); - if (targetChanged) { - this.eventHandlers.onTargetChange?.(lastTargetNode, hoveredNode); - } - - let placeholderDimensions, index, placement: Placement; - const children = hoveredNode.getChildren(); + private getDropPosition(node: NodeType, mouseX: number, mouseY: number) { + let { nodeDimensions, childrenDimensions } = node; + const children = node.getChildren(); const nodeHasChildren = children && children.length > 0; - const hoveredNodeDimensions = this.getDim(hoveredNode.element!); - const childrenDimensions = - targetChanged || !!!lastChildrenDimensions ? this.getChildrenDim(hoveredNode) : lastChildrenDimensions; + nodeDimensions = !nodeDimensions ? this.getDim(node.element!) : nodeDimensions; + node.nodeDimensions = nodeDimensions; + + childrenDimensions = !childrenDimensions ? this.getChildrenDim(node) : childrenDimensions; + node.childrenDimensions = childrenDimensions; + let placeholderDimensions = nodeDimensions.clone(), + index = 0, + placement = 'inside' as Placement; if (nodeHasChildren) { ({ index, placement } = findPosition(childrenDimensions, mouseX, mouseY)); - placeholderDimensions = childrenDimensions[index]; - } else { - placeholderDimensions = hoveredNodeDimensions; - index = 0; - placement = 'inside'; + placeholderDimensions = childrenDimensions[index].clone(); + index = index + (placement == 'after' ? 1 : 0); } - index = index + (placement == 'after' ? 1 : 0); - this.eventHandlers.onPlaceholderPositionChange?.(placeholderDimensions, placement); - - this.lastMoveData = { - lastTargetNode: hoveredNode, - lastTargetDimensions: hoveredNodeDimensions, - lastChildrenDimensions: childrenDimensions, - lastIndex: index, - lastPlacement: placement, + return { + index, + placement, + placeholderDimensions, }; - - return index; } - private getTargetNode(mouseEvent: MouseEvent) { - const customTarget = this.containerContext.customTarget; + /** + * Retrieves the target node based on the mouse event. + * Determines the element being hovered, its corresponding model, and + * calculates the valid parent node to use as the target node. + * + * @param mouseEvent - The mouse event containing the cursor position and target element. + * @returns The target node if a valid one is found, otherwise undefined. + */ + private getTargetNode(mouseEvent: MouseEvent): NodeType | undefined { this.cacheContainerPosition(this.containerContext.container); + // Get the element under the mouse + const mouseTargetEl = this.getMouseTargetElement(mouseEvent); + const targetEl = this.getFirstElementWithAModel(mouseTargetEl); + if (!targetEl) return; + const hoveredModel = $(targetEl)?.data('model'); + if (!hoveredModel) return; + + let hoveredNode = this.getOrCreateHoveredNode(hoveredModel); + + // Get the drop position index based on the mouse position + const { index } = this.getDropPosition(hoveredNode, mouseEvent.clientX, mouseEvent.clientY); + + // Determine the valid target node (or its valid parent) + let targetNode = this.getValidParent(hoveredNode, index, mouseEvent.clientX, mouseEvent.clientY); + + return this.getOrReuseTargetNode(targetNode); + } + + /** + * Creates a new hovered node or reuses the last hovered node if it is the same. + * + * @param hoveredModel - The model corresponding to the hovered element. + * @returns The new or reused hovered node. + */ + private getOrCreateHoveredNode(hoveredModel: T): NodeType { + const lastHoveredNode = this.lastMoveData.hoveredNode; + const hoveredNode = new this.treeClass(hoveredModel); + const newHoveredNode = hoveredNode.equals(lastHoveredNode) ? lastHoveredNode : hoveredNode; + this.lastMoveData.hoveredNode = newHoveredNode; + + return newHoveredNode; + } + + /** + * Checks if the target node has changed and returns the last one if they are identical. + * + * @param targetNode - The newly calculated target node. + * @returns The new or reused target node. + */ + private getOrReuseTargetNode(targetNode?: NodeType): NodeType | undefined { + const lastTargetNode = this.lastMoveData.targetNode; + return targetNode?.equals(lastTargetNode) ? lastTargetNode : targetNode; + } + + private getMouseTargetElement(mouseEvent: MouseEvent) { + const customTarget = this.containerContext.customTarget; let mouseTarget = this.containerContext.document.elementFromPoint( mouseEvent.clientX, mouseEvent.clientY, ) as HTMLElement; let mouseTargetEl: HTMLElement | null = customTarget ? customTarget({ event: mouseEvent }) : mouseTarget; - const targetEl = this.getFirstElementWithAModel(mouseTargetEl); - if (!targetEl) return; - const targetModel = $(targetEl)?.data('model'); - const mouseTargetNode = new this.treeClass(targetModel); - const targetNode = this.getValidParentNode(mouseTargetNode); - return targetNode; + + return mouseTargetEl; } private onDragStart(mouseEvent: MouseEvent): void { @@ -253,7 +321,7 @@ export class DropLocationDeterminer> ext } cancelDrag() { - const { lastTargetNode } = this.lastMoveData; + const { targetNode: lastTargetNode } = this.lastMoveData; this.eventHandlers.onTargetChange?.(lastTargetNode, undefined); this.finalizeMove(); } @@ -264,16 +332,17 @@ export class DropLocationDeterminer> ext this.eventHandlers.onEnd?.(); this.eventHandlers.legacyOnEnd?.(); this.restLastMoveData(); + this.rateLimiter.clearTimeout(); } private dropDragged() { - const { lastTargetNode, lastIndex } = this.lastMoveData; + const { targetNode: lastTargetNode, index: lastIndex } = this.lastMoveData; this.eventHandlers.onDrop?.(lastTargetNode, this.sourceNodes, lastIndex); this.finalizeMove(); } private triggerOnDragEndEvent() { - const { lastTargetNode: targetNode } = this.lastMoveData; + const { targetNode: targetNode } = this.lastMoveData; // For backward compatibility, leave it to a single node const firstSourceNode = this.sourceNodes[0]; @@ -317,25 +386,36 @@ export class DropLocationDeterminer> ext return null; } - private getValidParentNode(targetNode: NodeType) { - let finalNode = targetNode; - while (finalNode !== null) { - const canMove = this.sourceNodes.some((node) => finalNode.canMove(node, 0)); - - // For backward compatibility, leave it to a single node - const firstSource = this.sourceNodes[0]; - this.em.trigger('sorter:drag:validation', { - valid: canMove, - src: firstSource?.element, - srcModel: firstSource?.model, - trg: finalNode.element, - trgModel: finalNode.model, - }); - if (canMove) break; - finalNode = finalNode.getParent()! as NodeType; - } + private getValidParent(targetNode: NodeType, index: number, mouseX: number, mouseY: number): NodeType | undefined { + if (!targetNode) return; + const positionNotChanged = targetNode.equals(this.lastMoveData.targetNode) && index === this.lastMoveData.index; + if (positionNotChanged) return targetNode; + + const canMove = this.sourceNodes.some((node) => targetNode.canMove(node, index)); + this.triggerDragValidation(canMove, targetNode); + if (canMove) return targetNode; - return finalNode; + const parent = targetNode.getParent() as NodeType; + if (!parent) return; + + let indexInParent = parent?.indexOfChild(targetNode); + const nodeDimensions = this.getDim(targetNode.element!); + nodeDimensions.dir = this.getDirection(targetNode.element!, parent.element!); + indexInParent = indexInParent + (nodeDimensions.determinePlacement(mouseX, mouseY) == 'after' ? 1 : 0); + return this.getValidParent(parent, indexInParent, mouseX, mouseY); + } + + private triggerDragValidation(canMove: boolean, targetNode: NodeType) { + const firstSource = this.sourceNodes[0]; + this.em.trigger('sorter:drag:validation', { + valid: canMove, + src: firstSource?.element, + srcModel: firstSource?.model, + trg: targetNode.element, + trgModel: targetNode.model, + }); + + return firstSource; } /** @@ -352,6 +432,25 @@ export class DropLocationDeterminer> ext off(this.containerContext.document, 'mouseup dragend touchend', this.endDrag); } + /** + * Determines if an element is in the normal flow of the document. + * This checks whether the element is not floated or positioned in a way that removes it from the flow. + * + * @param {HTMLElement} el - The element to check. + * @param {HTMLElement} [parent=document.body] - The parent element for additional checks (defaults to `document.body`). + * @return {boolean} Returns `true` if the element is in flow, otherwise `false`. + * @private + */ + private getDirection(el: HTMLElement, parent: HTMLElement): boolean { + let dirValue: boolean; + + if (this.dragDirection === DragDirection.Vertical) dirValue = true; + else if (this.dragDirection === DragDirection.Horizontal) dirValue = false; + else dirValue = isStyleInFlow(el, parent); + + return dirValue; + } + /** * Get children dimensions * @param {NodeType} el Element root @@ -378,14 +477,7 @@ export class DropLocationDeterminer> ext } const dim = this.getDim(el); - let dir = this.dragDirection; - let dirValue: boolean; - - if (dir === DragDirection.Vertical) dirValue = true; - else if (dir === DragDirection.Horizontal) dirValue = false; - else dirValue = isInFlow(el, targetElement); - - dim.dir = dirValue; + dim.dir = this.getDirection(el, targetElement); dims.push(dim); }); @@ -463,6 +555,6 @@ export class DropLocationDeterminer> ext width = el.offsetWidth; } - return { top, left, height, width, offsets }; + return new Dimension({ top, left, height, width, offsets }); } } diff --git a/packages/core/src/utils/sorter/PlaceholderClass.ts b/packages/core/src/utils/sorter/PlaceholderClass.ts index f501ca541..b87f38ce6 100644 --- a/packages/core/src/utils/sorter/PlaceholderClass.ts +++ b/packages/core/src/utils/sorter/PlaceholderClass.ts @@ -1,5 +1,12 @@ import { View } from '../../common'; -import { Dimension, Placement } from './types'; +import { Placement } from './types'; +import Dimension from './Dimension'; +import { RateLimiter } from './RateLimiter'; + +type PlaceHolderPosition = { + elementDimension: Dimension; + placement: Placement; +}; export class PlaceholderClass extends View { pfx: string; @@ -10,6 +17,8 @@ export class PlaceholderClass extends View { top: number; left: number; }; + private moveLimiter: RateLimiter; + constructor(options: { container: HTMLElement; pfx?: string; @@ -29,6 +38,9 @@ export class PlaceholderClass extends View { top: options.offset.top || 0, left: options.offset.left || 0, }; + + // Initialize the RateLimiter with the moveThreshold + this.moveLimiter = new RateLimiter(100); } show() { @@ -36,15 +48,28 @@ export class PlaceholderClass extends View { } hide() { + this.moveLimiter.clearTimeout(); this.el.style.display = 'none'; } /** - * Updates the position of the placeholder. + * Updates the position of the placeholder with a movement threshold. * @param {Dimension} elementDimension element dimensions. - * @param {Position} placement either before or after the target. + * @param {Placement} placement either before or after the target. */ move(elementDimension: Dimension, placement: Placement) { + const position: PlaceHolderPosition = { elementDimension, placement }; + + // Update the position arguments in the RateLimiter + this.moveLimiter.updateArgs(position); + + // Execute the callback with a threshold + this.moveLimiter.execute(({ elementDimension, placement }) => { + this._move(elementDimension, placement); + }); + } + + private _move(elementDimension: Dimension, placement: Placement) { const marginOffset = 0; const unit = 'px'; let top = 0; diff --git a/packages/core/src/utils/sorter/RateLimiter.ts b/packages/core/src/utils/sorter/RateLimiter.ts new file mode 100644 index 000000000..d03edb1d4 --- /dev/null +++ b/packages/core/src/utils/sorter/RateLimiter.ts @@ -0,0 +1,31 @@ +export class RateLimiter { + private threshold: number; + private lastArgs: T | undefined; + private timeout: NodeJS.Timeout | null = null; + + constructor(threshold: number) { + this.threshold = threshold; + } + + updateArgs(args: T) { + this.lastArgs = args; + } + + execute(callback: (args: T) => void) { + if (!this.timeout) { + this.timeout = setTimeout(() => { + if (this.lastArgs) { + callback(this.lastArgs); + } + this.timeout = null; + }, this.threshold); + } + } + + clearTimeout() { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + } +} diff --git a/packages/core/src/utils/sorter/SortableTreeNode.ts b/packages/core/src/utils/sorter/SortableTreeNode.ts index 5bcdc12e5..ee18b619b 100644 --- a/packages/core/src/utils/sorter/SortableTreeNode.ts +++ b/packages/core/src/utils/sorter/SortableTreeNode.ts @@ -1,4 +1,5 @@ import { View } from '../../common'; +import Dimension from './Dimension'; import { DragSource } from './types'; /** @@ -9,6 +10,10 @@ import { DragSource } from './types'; export abstract class SortableTreeNode { protected _model: T; protected _dragSource: DragSource; + /** The dimensions of the node. */ + public nodeDimensions?: Dimension; + /** The dimensions of the child elements within the target node. */ + public childrenDimensions?: Dimension[]; constructor(model: T, dragSource: DragSource = {}) { this._model = model; this._dragSource = dragSource; @@ -87,7 +92,14 @@ export abstract class SortableTreeNode { return this._dragSource; } - equals(node?: SortableTreeNode): boolean { + equals(node?: SortableTreeNode): node is SortableTreeNode { return !!node?._model && this._model === node._model; } + + adjustDimensions(diff: { topDifference: number; leftDifference: number }) { + if (diff.topDifference === 0 && diff.leftDifference === 0) return; + + this.nodeDimensions?.adjustDimensions(diff); + this.childrenDimensions?.forEach((dims) => dims.adjustDimensions(diff)); + } } diff --git a/packages/core/src/utils/sorter/Sorter.ts b/packages/core/src/utils/sorter/Sorter.ts index 7c852f9b5..747c7d02f 100644 --- a/packages/core/src/utils/sorter/Sorter.ts +++ b/packages/core/src/utils/sorter/Sorter.ts @@ -12,9 +12,9 @@ import { PositionOptions, SorterDragBehaviorOptions, SorterEventHandlers, - Dimension, Placement, } from './types'; +import Dimension from './Dimension'; import { SorterOptions } from './types'; export default class Sorter> { @@ -107,7 +107,7 @@ export default class Sorter> { /** * This method is should be called when the user scrolls within the container. */ - recalculateTargetOnScroll(): void { + protected recalculateTargetOnScroll(): void { this.dropLocationDeterminer.recalculateTargetOnScroll(); } @@ -183,7 +183,7 @@ export default class Sorter> { return sourceElement; } - private bindDragEventHandlers() { + protected bindDragEventHandlers() { on(this.containerContext.document, 'keydown', this.rollback); } @@ -196,7 +196,7 @@ export default class Sorter> { * * @private */ - private cleanupEventListeners(): void { + protected cleanupEventListeners(): void { off(this.containerContext.document, 'keydown', this.rollback); } diff --git a/packages/core/src/utils/sorter/SorterUtils.ts b/packages/core/src/utils/sorter/SorterUtils.ts index 3c588a78a..4f1029683 100644 --- a/packages/core/src/utils/sorter/SorterUtils.ts +++ b/packages/core/src/utils/sorter/SorterUtils.ts @@ -3,7 +3,8 @@ import EditorModel from '../../editor/model/Editor'; import { isTextNode } from '../dom'; import { matches as matchesMixin } from '../mixins'; import { SortableTreeNode } from './SortableTreeNode'; -import { Dimension, Placement, DragDirection, SorterOptions } from './types'; +import { Placement, DragDirection, SorterOptions } from './types'; +import Dimension from './Dimension'; /** * Find the position based on passed dimensions and coordinates @@ -133,18 +134,6 @@ export function closest(el: HTMLElement, selector: string): HTMLElement | undefi elem = elem.parentNode; } } -/** - * Determines if an element is in the normal flow of the document. - * This checks whether the element is not floated or positioned in a way that removes it from the flow. - * - * @param {HTMLElement} el - The element to check. - * @param {HTMLElement} [parent=document.body] - The parent element for additional checks (defaults to `document.body`). - * @return {boolean} Returns `true` if the element is in flow, otherwise `false`. - * @private - */ -export function isInFlow(el: HTMLElement, parent: HTMLElement = document.body): boolean { - return !!el && isStyleInFlow(el, parent); -} /** * Checks if an element has styles that keep it in the document flow. @@ -155,7 +144,7 @@ export function isInFlow(el: HTMLElement, parent: HTMLElement = document.body): * @return {boolean} Returns `true` if the element is styled to be in flow, otherwise `false`. * @private */ -function isStyleInFlow(el: HTMLElement, parent: HTMLElement): boolean { +export function isStyleInFlow(el: HTMLElement, parent: HTMLElement): boolean { if (isTextNode(el)) return false; const elementStyles = el.style || {}; diff --git a/packages/core/src/utils/sorter/types.ts b/packages/core/src/utils/sorter/types.ts index 450c35ee2..04d33baae 100644 --- a/packages/core/src/utils/sorter/types.ts +++ b/packages/core/src/utils/sorter/types.ts @@ -1,6 +1,6 @@ -import CanvasModule from '../../canvas'; import { ComponentDefinition } from '../../dom_components/model/types'; import EditorModel from '../../editor/model/Editor'; +import Dimension from './Dimension'; import { SortableTreeNode } from './SortableTreeNode'; export type ContentElement = string | ComponentDefinition; @@ -24,17 +24,6 @@ export type DragSource = DraggableContent & { model?: T; }; -export interface Dimension { - top: number; - left: number; - height: number; - width: number; - offsets: ReturnType; - dir?: boolean; - el?: HTMLElement; - indexEl?: number; -} - export type Placement = 'inside' | 'before' | 'after'; export enum DragDirection { From 336f4b81e66206dcd3e067c8f94bf2d0e89533f3 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Sat, 5 Oct 2024 11:30:12 +0400 Subject: [PATCH 2/6] Feature detect document + fixes on configs (#6189) * Add frame and page getters to Component * Allow to skip dom reset in components * Add detectDocument option to parser config * Add __attrToString * Update all configs in modules to avoid objects by reference * Add tests to detectDocument * Unskip some tests --- .../core/src/asset_manager/config/config.ts | 4 +- packages/core/src/asset_manager/index.ts | 4 +- .../core/src/block_manager/config/config.ts | 4 +- packages/core/src/block_manager/index.ts | 4 +- packages/core/src/canvas/config/config.ts | 4 +- packages/core/src/canvas/index.ts | 4 +- .../core/src/code_manager/config/config.ts | 4 +- packages/core/src/code_manager/index.ts | 4 +- packages/core/src/commands/config/config.ts | 4 +- packages/core/src/commands/index.ts | 4 +- .../core/src/css_composer/config/config.ts | 4 +- packages/core/src/css_composer/index.ts | 4 +- .../core/src/device_manager/config/config.ts | 4 +- packages/core/src/device_manager/index.ts | 4 +- .../core/src/dom_components/config/config.ts | 6 +- packages/core/src/dom_components/index.ts | 11 +-- .../src/dom_components/model/Component.ts | 75 +++++++++++-------- .../dom_components/model/ComponentWrapper.ts | 13 ++-- .../src/dom_components/model/Components.ts | 5 +- .../core/src/dom_components/model/types.ts | 6 ++ .../src/dom_components/view/ComponentsView.ts | 10 ++- packages/core/src/editor/config/config.ts | 4 +- packages/core/src/editor/index.ts | 3 +- packages/core/src/editor/model/Editor.ts | 1 + packages/core/src/i18n/config.ts | 4 +- packages/core/src/i18n/index.ts | 4 +- packages/core/src/keymaps/config.ts | 4 +- packages/core/src/keymaps/index.ts | 4 +- .../core/src/modal_dialog/config/config.ts | 4 +- packages/core/src/modal_dialog/index.ts | 4 +- packages/core/src/navigator/config/config.ts | 4 +- packages/core/src/navigator/index.ts | 4 +- packages/core/src/panels/config/config.ts | 4 +- packages/core/src/panels/index.ts | 9 +-- packages/core/src/parser/config/config.ts | 9 ++- packages/core/src/parser/index.ts | 4 +- packages/core/src/parser/model/ParserHtml.ts | 19 ++++- .../src/rich_text_editor/config/config.ts | 4 +- packages/core/src/rich_text_editor/index.ts | 4 +- .../src/selector_manager/config/config.ts | 4 +- packages/core/src/selector_manager/index.ts | 5 +- .../core/src/storage_manager/config/config.ts | 4 +- packages/core/src/storage_manager/index.ts | 4 +- .../core/src/style_manager/config/config.ts | 6 +- packages/core/src/style_manager/index.ts | 4 +- .../core/src/trait_manager/config/config.ts | 4 +- packages/core/src/trait_manager/index.ts | 4 +- packages/core/src/undo_manager/config.ts | 4 +- packages/core/src/undo_manager/index.ts | 4 +- packages/core/test/common.ts | 11 +++ .../core/test/specs/dom_components/index.ts | 49 +++++++++--- packages/core/test/specs/grapesjs/index.ts | 73 ++++++++++++------ .../view/{ItemView.js => ItemView.ts} | 14 ++-- 53 files changed, 284 insertions(+), 181 deletions(-) rename packages/core/test/specs/navigator/view/{ItemView.js => ItemView.ts} (72%) diff --git a/packages/core/src/asset_manager/config/config.ts b/packages/core/src/asset_manager/config/config.ts index 09c5def67..34ca1c593 100644 --- a/packages/core/src/asset_manager/config/config.ts +++ b/packages/core/src/asset_manager/config/config.ts @@ -141,7 +141,7 @@ export interface AssetManagerConfig { dropzoneContent?: string; } -const config: AssetManagerConfig = { +const config: () => AssetManagerConfig = () => ({ assets: [], noAssets: '', stylePrefix: 'am-', @@ -163,6 +163,6 @@ const config: AssetManagerConfig = { dropzone: false, openAssetsOnDrop: true, dropzoneContent: '', -}; +}); export default config; diff --git a/packages/core/src/asset_manager/index.ts b/packages/core/src/asset_manager/index.ts index b6d547531..cfee7a7b4 100644 --- a/packages/core/src/asset_manager/index.ts +++ b/packages/core/src/asset_manager/index.ts @@ -37,7 +37,7 @@ import { ItemManagerModule } from '../abstract/Module'; import { AddOptions, RemoveOptions } from '../common'; import EditorModel from '../editor/model/Editor'; import { ProjectData } from '../storage_manager'; -import defaults, { AssetManagerConfig } from './config/config'; +import defConfig, { AssetManagerConfig } from './config/config'; import Asset from './model/Asset'; import Assets from './model/Assets'; import AssetsEvents, { AssetOpenOptions } from './types'; @@ -66,7 +66,7 @@ export default class AssetManager extends ItemManagerModule BlockManagerConfig = () => ({ appendTo: '', blocks: [], appendOnClick: false, custom: false, -}; +}); export default config; diff --git a/packages/core/src/block_manager/index.ts b/packages/core/src/block_manager/index.ts index eb3a5ecdb..a134e11fe 100644 --- a/packages/core/src/block_manager/index.ts +++ b/packages/core/src/block_manager/index.ts @@ -31,7 +31,7 @@ import { ItemManagerModule } from '../abstract/Module'; import FrameView from '../canvas/view/FrameView'; import Component from '../dom_components/model/Component'; import EditorModel from '../editor/model/Editor'; -import defaults, { BlockManagerConfig } from './config/config'; +import defConfig, { BlockManagerConfig } from './config/config'; import Block, { BlockProperties } from './model/Block'; import Blocks from './model/Blocks'; import Categories from '../abstract/ModuleCategories'; @@ -61,7 +61,7 @@ export default class BlockManager extends ItemManagerModule CanvasConfig = () => ({ stylePrefix: 'cv-', scripts: [], styles: [], @@ -107,6 +107,6 @@ const config: CanvasConfig = { `, notTextable: ['button', 'a', 'input[type=checkbox]', 'input[type=radio]'], allowExternalDrop: true, -}; +}); export default config; diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index ae2b5fa4e..c3c84fa1f 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -35,7 +35,7 @@ import Component from '../dom_components/model/Component'; import ComponentView from '../dom_components/view/ComponentView'; import EditorModel from '../editor/model/Editor'; import { getElement, getViewEl } from '../utils/mixins'; -import defaults, { CanvasConfig } from './config/config'; +import defConfig, { CanvasConfig } from './config/config'; import Canvas from './model/Canvas'; import CanvasSpot, { CanvasSpotBuiltInTypes, CanvasSpotProps } from './model/CanvasSpot'; import CanvasSpots from './model/CanvasSpots'; @@ -76,7 +76,7 @@ export default class CanvasModule extends Module { * @private */ constructor(em: EditorModel) { - super(em, 'Canvas', defaults); + super(em, 'Canvas', defConfig()); this.canvas = new Canvas(this); this.spots = new CanvasSpots(this); diff --git a/packages/core/src/code_manager/config/config.ts b/packages/core/src/code_manager/config/config.ts index 2d0379e78..44c3e2e89 100644 --- a/packages/core/src/code_manager/config/config.ts +++ b/packages/core/src/code_manager/config/config.ts @@ -12,9 +12,9 @@ export interface CodeManagerConfig { optsCodeViewer?: Record; } -const config: CodeManagerConfig = { +const config: () => CodeManagerConfig = () => ({ stylePrefix: 'cm-', optsCodeViewer: {}, -}; +}); export default config; diff --git a/packages/core/src/code_manager/index.ts b/packages/core/src/code_manager/index.ts index e987bfd5f..2e6102f56 100644 --- a/packages/core/src/code_manager/index.ts +++ b/packages/core/src/code_manager/index.ts @@ -18,7 +18,7 @@ * @module CodeManager */ import { isUndefined } from 'underscore'; -import defaults, { CodeManagerConfig } from './config/config'; +import defConfig, { CodeManagerConfig } from './config/config'; import gHtml from './model/HtmlGenerator'; import gCss from './model/CssGenerator'; import gJson from './model/JsonGenerator'; @@ -39,7 +39,7 @@ export default class CodeManagerModule extends Module CommandsConfig = () => ({ stylePrefix: 'com-', defaults: {}, strict: true, -}; +}); export default config; diff --git a/packages/core/src/commands/index.ts b/packages/core/src/commands/index.ts index 3db755134..0cd98354c 100644 --- a/packages/core/src/commands/index.ts +++ b/packages/core/src/commands/index.ts @@ -37,7 +37,7 @@ import { isFunction, includes } from 'underscore'; import CommandAbstract, { Command, CommandOptions, CommandObject, CommandFunction } from './view/CommandAbstract'; -import defaults, { CommandsConfig } from './config/config'; +import defConfig, { CommandsConfig } from './config/config'; import { Module } from '../abstract'; import Component, { eventDrag } from '../dom_components/model/Component'; import Editor from '../editor/model/Editor'; @@ -107,7 +107,7 @@ export default class CommandsModule extends Module; // TODO } -const config: CssComposerConfig = { +const config: () => CssComposerConfig = () => ({ stylePrefix: 'css-', rules: [], -}; +}); export default config; diff --git a/packages/core/src/css_composer/index.ts b/packages/core/src/css_composer/index.ts index 0e226663a..d3783eeb9 100644 --- a/packages/core/src/css_composer/index.ts +++ b/packages/core/src/css_composer/index.ts @@ -31,7 +31,7 @@ import { isArray, isString, isUndefined } from 'underscore'; import { isObject } from '../utils/mixins'; import Selectors from '../selector_manager/model/Selectors'; import Selector from '../selector_manager/model/Selector'; -import defaults, { CssComposerConfig } from './config/config'; +import defConfig, { CssComposerConfig } from './config/config'; import CssRule, { CssRuleJSON, CssRuleProperties } from './model/CssRule'; import CssRules from './model/CssRules'; import CssRulesView from './view/CssRulesView'; @@ -85,7 +85,7 @@ export default class CssComposer extends ItemManagerModule DeviceManagerConfig = () => ({ default: '', devices: [ { @@ -52,6 +52,6 @@ const config: DeviceManagerConfig = { widthMedia: '480px', }, ], -}; +}); export default config; diff --git a/packages/core/src/device_manager/index.ts b/packages/core/src/device_manager/index.ts index bd0c3d1ff..a0d1a3668 100644 --- a/packages/core/src/device_manager/index.ts +++ b/packages/core/src/device_manager/index.ts @@ -35,7 +35,7 @@ import { isString } from 'underscore'; import { ItemManagerModule } from '../abstract/Module'; import EditorModel from '../editor/model/Editor'; -import defaults, { DeviceManagerConfig } from './config/config'; +import defConfig, { DeviceManagerConfig } from './config/config'; import Device, { DeviceProperties } from './model/Device'; import Devices from './model/Devices'; import DevicesView from './view/DevicesView'; @@ -74,7 +74,7 @@ export default class DeviceManager extends ItemManagerModule< storageKey = ''; constructor(em: EditorModel) { - super(em, 'DeviceManager', new Devices(), deviceEvents, defaults); + super(em, 'DeviceManager', new Devices(), deviceEvents, defConfig()); this.devices = this.all; this.config.devices?.forEach((device) => this.add(device, { silent: true })); this.select(this.config.default || this.devices.at(0)); diff --git a/packages/core/src/dom_components/config/config.ts b/packages/core/src/dom_components/config/config.ts index e184c03a2..a7bff2d54 100644 --- a/packages/core/src/dom_components/config/config.ts +++ b/packages/core/src/dom_components/config/config.ts @@ -62,7 +62,7 @@ export interface DomComponentsConfig { useFrameDoc?: boolean; } -export default { +const config: () => DomComponentsConfig = () => ({ stylePrefix: 'comp-', components: [], draggableComponents: true, @@ -87,4 +87,6 @@ export default { 'track', 'wbr', ], -} as DomComponentsConfig; +}); + +export default config; diff --git a/packages/core/src/dom_components/index.ts b/packages/core/src/dom_components/index.ts index 8e987c5a4..008a0380a 100644 --- a/packages/core/src/dom_components/index.ts +++ b/packages/core/src/dom_components/index.ts @@ -55,10 +55,10 @@ */ import { debounce, isArray, isBoolean, isEmpty, isFunction, isString, isSymbol, result } from 'underscore'; import { ItemManagerModule } from '../abstract/Module'; -import { AddOptions, ObjectAny } from '../common'; +import { ObjectAny } from '../common'; import EditorModel from '../editor/model/Editor'; import { isComponent } from '../utils/mixins'; -import defaults, { DomComponentsConfig } from './config/config'; +import defConfig, { DomComponentsConfig } from './config/config'; import Component, { IComponent, keyUpdate, keyUpdateInside } from './model/Component'; import ComponentComment from './model/ComponentComment'; import ComponentFrame from './model/ComponentFrame'; @@ -334,7 +334,7 @@ export default class ComponentManager extends ItemManagerModule { return this.get('locked'); } + get frame() { + return this.opt.frame; + } + + get page() { + return this.frame?.getPage(); + } + + preInit() {} + /** * Hook method, called once the model is created */ @@ -238,7 +248,6 @@ export default class Component extends StyleableModel { views!: ComponentView[]; view?: ComponentView; viewLayer?: ItemView; - frame?: Frame; rule?: CssRule; prevColl?: Components; __hasUm?: boolean; @@ -275,13 +284,13 @@ export default class Component extends StyleableModel { opt.em = em; this.opt = opt; this.em = em!; - this.frame = opt.frame; this.config = opt.config || {}; this.set('attributes', { ...(result(this, 'defaults').attributes || {}), ...(this.get('attributes') || {}), }); this.ccid = Component.createId(this, opt); + this.preInit(); this.initClasses(); this.initComponents(); this.initTraits(); @@ -999,7 +1008,7 @@ export default class Component extends StyleableModel { */ components( components?: T, - opts: AddComponentsOption = {}, + opts: ResetComponentsOptions = {}, ): undefined extends T ? Components : Component[] { const coll = this.get('components')!; @@ -1428,19 +1437,43 @@ export default class Component extends StyleableModel { * // -> */ toHTML(opts: ToHTMLOptions = {}): string { - const model = this; - const attrs = []; const customTag = opts.tag; - const tag = customTag || model.get('tagName'); - const sTag = model.get('void'); + const tag = customTag || this.get('tagName'); + delete opts.tag; + + const attr = this.__attrToString(opts); + const attrString = attr ? ` ${attr}` : ''; + const inner = this.getInnerHTML(opts); + const skipEndTag = !inner && this.get('void'); + let code = `<${tag}${attrString}${skipEndTag ? '/' : ''}>${inner}`; + !skipEndTag && (code += ``); + + return code; + } + + /** + * Get inner HTML of the component + * @param {Object} [opts={}] Same options of `toHTML` + * @returns {String} HTML string + */ + getInnerHTML(opts?: ToHTMLOptions) { + return this.__innerHTML(opts); + } + + __innerHTML(opts: ToHTMLOptions = {}) { + const cmps = this.components(); + return !cmps.length ? this.content : cmps.map((c) => c.toHTML(opts)).join(''); + } + + __attrToString(opts: ToHTMLOptions = {}) { + const attrs = []; const customAttr = opts.attributes; let attributes = this.getAttrToHTML(opts); - delete opts.tag; // Get custom attributes if requested if (customAttr) { if (isFunction(customAttr)) { - attributes = customAttr(model, attributes) || {}; + attributes = customAttr(this, attributes) || {}; } else if (isObject(customAttr)) { attributes = customAttr; } @@ -1448,7 +1481,6 @@ export default class Component extends StyleableModel { if (opts.withProps) { const props = this.toJSON(); - forEach(props, (value, key) => { const skipProps = ['classes', 'attributes', 'components']; if (key[0] !== '_' && skipProps.indexOf(key) < 0) { @@ -1478,26 +1510,7 @@ export default class Component extends StyleableModel { } } - const attrString = attrs.length ? ` ${attrs.join(' ')}` : ''; - const inner = model.getInnerHTML(opts); - let code = `<${tag}${attrString}${sTag ? '/' : ''}>${inner}`; - !sTag && (code += ``); - - return code; - } - - /** - * Get inner HTML of the component - * @param {Object} [opts={}] Same options of `toHTML` - * @returns {String} HTML string - */ - getInnerHTML(opts?: ToHTMLOptions) { - return this.__innerHTML(opts); - } - - __innerHTML(opts: ToHTMLOptions = {}) { - const cmps = this.components(); - return !cmps.length ? this.content : cmps.map((c) => c.toHTML(opts)).join(''); + return attrs.join(' '); } /** diff --git a/packages/core/src/dom_components/model/ComponentWrapper.ts b/packages/core/src/dom_components/model/ComponentWrapper.ts index 615276556..defc6cc8c 100644 --- a/packages/core/src/dom_components/model/ComponentWrapper.ts +++ b/packages/core/src/dom_components/model/ComponentWrapper.ts @@ -30,19 +30,16 @@ export default class ComponentWrapper extends Component { }; } - constructor(...args: ConstructorParameters) { - super(...args); - const props = args[0] || {}; - const opts = args[1]; - - const cmp = opts?.em?.Components; + preInit() { + const { opt, attributes: props } = this; + const cmp = this.em?.Components; const CmpHead = cmp?.getType(typeHead)?.model; const CmpDef = cmp?.getType('default').model; if (CmpHead) { this.set( { - head: new CmpHead({ ...props.head }, opts), - docEl: new CmpDef({ tagName: 'html', ...props.docEl }, opts), + head: new CmpHead({ ...props.head }, opt), + docEl: new CmpDef({ tagName: 'html', ...props.docEl }, opt), }, { silent: true }, ); diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index 452fd30ee..a8d225412 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/packages/core/src/dom_components/model/Components.ts @@ -254,12 +254,13 @@ Component> { parseString(value: string, opt: ParseStringOptions = {}) { const { em, domc, parent } = this; - const asDocument = opt.asDocument && parent?.is('wrapper'); + const isWrapper = parent?.is('wrapper'); + const asDocument = opt.asDocument && isWrapper; const cssc = em.Css; const parsed = em.Parser.parseHtml(value, { asDocument, ...opt.parserOptions }); let components = parsed.html; - if (asDocument) { + if (isWrapper && parsed.doctype) { const root = parent as ComponentWrapper; const { components: bodyCmps, ...restBody } = (parsed.html as ComponentDefinitionDefined) || {}; const { components: headCmps, ...restHead } = parsed.head || {}; diff --git a/packages/core/src/dom_components/model/types.ts b/packages/core/src/dom_components/model/types.ts index 8364d00d4..10df3a2d1 100644 --- a/packages/core/src/dom_components/model/types.ts +++ b/packages/core/src/dom_components/model/types.ts @@ -19,6 +19,12 @@ export type DraggableDroppableFn = (source: Component, target: Component, index? export interface AddComponentsOption extends AddOptions, OptionAsDocument {} +export interface ResetComponentsOptions extends AddComponentsOption { + previousModels?: Component[]; + keepIds?: string[]; + skipDomReset?: boolean; +} + interface ComponentWithCheck { new (props: any, opt: ComponentOptions): C; isComponent(node: HTMLElement, opts?: ParseNodeOptions): ComponentDefinitionDefined | undefined | boolean; diff --git a/packages/core/src/dom_components/view/ComponentsView.ts b/packages/core/src/dom_components/view/ComponentsView.ts index 15bcb5601..7ae09e0b0 100644 --- a/packages/core/src/dom_components/view/ComponentsView.ts +++ b/packages/core/src/dom_components/view/ComponentsView.ts @@ -7,6 +7,7 @@ import Component from '../model/Component'; import ComponentView from './ComponentView'; import FrameView from '../../canvas/view/FrameView'; import Components from '../model/Components'; +import { ResetComponentsOptions } from '../model/types'; export default class ComponentsView extends View { opts!: any; @@ -124,9 +125,12 @@ export default class ComponentsView extends View { return rendered; } - resetChildren(models: Components, { previousModels = [] } = {}) { - this.parentEl!.innerHTML = ''; - previousModels.forEach((md) => this.removeChildren(md, this.collection)); + resetChildren(models: Components, opts: ResetComponentsOptions = {}) { + const { previousModels } = opts; + if (!opts.skipDomReset) { + this.parentEl!.innerHTML = ''; + } + previousModels?.forEach((md) => this.removeChildren(md, this.collection)); models.each((model) => this.addToCollection(model)); } diff --git a/packages/core/src/editor/config/config.ts b/packages/core/src/editor/config/config.ts index c3fd084d7..3b55d4571 100644 --- a/packages/core/src/editor/config/config.ts +++ b/packages/core/src/editor/config/config.ts @@ -432,7 +432,7 @@ export interface EditorConfig { export type EditorConfigKeys = keyof EditorConfig; -const config: EditorConfig = { +const config: () => EditorConfig = () => ({ stylePrefix: 'gjs-', components: '', style: '', @@ -506,6 +506,6 @@ const config: EditorConfig = { textViewCode: 'Code', keepUnusedStyles: false, customUI: false, -}; +}); export default config; diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 9d9bdc8e5..2ea4967b3 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -89,7 +89,7 @@ import TraitManager from '../trait_manager'; import UndoManagerModule from '../undo_manager'; import UtilsModule from '../utils'; import html from '../utils/html'; -import defaults, { EditorConfig, EditorConfigKeys } from './config/config'; +import defConfig, { EditorConfig, EditorConfigKeys } from './config/config'; import EditorModel, { EditorLoadOptions } from './model/Editor'; import EditorView from './view/EditorView'; @@ -132,6 +132,7 @@ export default class Editor implements IBaseModule { config: EditorConfigType; constructor(config: EditorConfig = {}, opts: any = {}) { + const defaults = defConfig(); this.config = { ...defaults, ...config, diff --git a/packages/core/src/editor/model/Editor.ts b/packages/core/src/editor/model/Editor.ts index e3d733117..33390b2ab 100644 --- a/packages/core/src/editor/model/Editor.ts +++ b/packages/core/src/editor/model/Editor.ts @@ -365,6 +365,7 @@ export default class EditorModel extends Model { storageManager: false, undoManager: false, }); + shallow.set({ isShallow: true }); // We only need to load a few modules shallow.Pages.onLoad(); shallow.Canvas.postLoad(); diff --git a/packages/core/src/i18n/config.ts b/packages/core/src/i18n/config.ts index 13e562cec..89ef27b24 100644 --- a/packages/core/src/i18n/config.ts +++ b/packages/core/src/i18n/config.ts @@ -37,13 +37,13 @@ export interface I18nConfig { messagesAdd?: Record; } -const config: I18nConfig = { +const config: () => I18nConfig = () => ({ locale: 'en', localeFallback: 'en', detectLocale: true, debug: false, messages: { en }, messagesAdd: undefined, -}; +}); export default config; diff --git a/packages/core/src/i18n/index.ts b/packages/core/src/i18n/index.ts index 231084c07..b964fb3ac 100644 --- a/packages/core/src/i18n/index.ts +++ b/packages/core/src/i18n/index.ts @@ -27,7 +27,7 @@ import { isUndefined, isString } from 'underscore'; import { Module } from '../abstract'; import EditorModel from '../editor/model/Editor'; import { hasWin, deepMerge } from '../utils/mixins'; -import defaults, { I18nConfig } from './config'; +import defConfig, { I18nConfig } from './config'; import I18nEvents, { Messages } from './types'; export default class I18nModule extends Module { @@ -39,7 +39,7 @@ export default class I18nModule extends Module & { opts?: KeymapOptions }>; } -const config: KeymapsConfig = { +const config: () => KeymapsConfig = () => ({ defaults: { 'core:undo': { keys: '⌘+z, ctrl+z', @@ -66,6 +66,6 @@ const config: KeymapsConfig = { opts: { prevent: true }, }, }, -}; +}); export default config; diff --git a/packages/core/src/keymaps/index.ts b/packages/core/src/keymaps/index.ts index 244b3e8a4..ce0b0659f 100644 --- a/packages/core/src/keymaps/index.ts +++ b/packages/core/src/keymaps/index.ts @@ -48,7 +48,7 @@ import { hasWin } from '../utils/mixins'; import keymaster from '../utils/keymaster'; import { Module } from '../abstract'; import EditorModel from '../editor/model/Editor'; -import defaults, { Keymap, KeymapOptions, KeymapsConfig } from './config'; +import defConfig, { Keymap, KeymapOptions, KeymapsConfig } from './config'; export type KeymapEvent = 'keymap:add' | 'keymap:remove' | 'keymap:emit' | `keymap:emit:${string}`; @@ -59,7 +59,7 @@ export default class KeymapsModule extends Module; constructor(em: EditorModel) { - super(em, 'Keymaps', defaults); + super(em, 'Keymaps', defConfig()); this.keymaps = {}; } diff --git a/packages/core/src/modal_dialog/config/config.ts b/packages/core/src/modal_dialog/config/config.ts index 9a4f6a995..c917e56f7 100644 --- a/packages/core/src/modal_dialog/config/config.ts +++ b/packages/core/src/modal_dialog/config/config.ts @@ -26,13 +26,13 @@ export interface ModalConfig { extend?: Record; } -const config: ModalConfig = { +const config: () => ModalConfig = () => ({ stylePrefix: 'mdl-', title: '', content: '', backdrop: true, custom: false, extend: {}, -}; +}); export default config; diff --git a/packages/core/src/modal_dialog/index.ts b/packages/core/src/modal_dialog/index.ts index f6112592c..36670c5c8 100644 --- a/packages/core/src/modal_dialog/index.ts +++ b/packages/core/src/modal_dialog/index.ts @@ -38,7 +38,7 @@ import { Module } from '../abstract'; import EditorView from '../editor/view/EditorView'; import EditorModel from '../editor/model/Editor'; import { createText } from '../utils/dom'; -import defaults, { ModalConfig } from './config/config'; +import defConfig, { ModalConfig } from './config/config'; import ModalM from './model/Modal'; import ModalView from './view/ModalView'; import { EventHandler } from '../common'; @@ -54,7 +54,7 @@ export default class ModalModule extends Module { * @private */ constructor(em: EditorModel) { - super(em, 'Modal', defaults); + super(em, 'Modal', defConfig()); this.model = new ModalM(this); this.model.on('change:open', (m: ModalM, enable: boolean) => { diff --git a/packages/core/src/navigator/config/config.ts b/packages/core/src/navigator/config/config.ts index ad31fa1a9..4c5f932c2 100644 --- a/packages/core/src/navigator/config/config.ts +++ b/packages/core/src/navigator/config/config.ts @@ -108,7 +108,7 @@ export interface LayerManagerConfig { extend?: Record; } -const config: LayerManagerConfig = { +const config: () => LayerManagerConfig = () => ({ stylePrefix: '', appendTo: '', sortable: true, @@ -124,6 +124,6 @@ const config: LayerManagerConfig = { onInit: () => {}, onRender: () => {}, extend: {}, -}; +}); export default config; diff --git a/packages/core/src/navigator/index.ts b/packages/core/src/navigator/index.ts index 01cf4a029..ad659580d 100644 --- a/packages/core/src/navigator/index.ts +++ b/packages/core/src/navigator/index.ts @@ -45,7 +45,7 @@ import Module from '../abstract/Module'; import Component from '../dom_components/model/Component'; import EditorModel from '../editor/model/Editor'; import { hasWin, isComponent, isDef } from '../utils/mixins'; -import defaults, { LayerManagerConfig } from './config/config'; +import defConfig, { LayerManagerConfig } from './config/config'; import View from './view/ItemView'; import { ComponentsEvents } from '../dom_components/types'; @@ -91,7 +91,7 @@ export default class LayerManager extends Module { events = events; constructor(em: EditorModel) { - super(em, 'LayerManager', defaults); + super(em, 'LayerManager', defConfig()); bindAll(this, 'componentChanged', '__onRootChange', '__onComponent'); this.model = new ModuleModel(this, { opened: {} }); // @ts-ignore diff --git a/packages/core/src/panels/config/config.ts b/packages/core/src/panels/config/config.ts index c8bfcc444..a227f6f75 100644 --- a/packages/core/src/panels/config/config.ts +++ b/packages/core/src/panels/config/config.ts @@ -34,7 +34,7 @@ export interface PanelsConfig { defaults?: PanelProps[]; } -const config: PanelsConfig = { +const config: () => PanelsConfig = () => ({ stylePrefix: 'pn-', defaults: [ { @@ -109,6 +109,6 @@ const config: PanelsConfig = { ], }, ], -}; +}); export default config; diff --git a/packages/core/src/panels/index.ts b/packages/core/src/panels/index.ts index fca2283b5..76614c833 100644 --- a/packages/core/src/panels/index.ts +++ b/packages/core/src/panels/index.ts @@ -27,7 +27,7 @@ */ import { Module } from '../abstract'; import EditorModel from '../editor/model/Editor'; -import defaults, { PanelsConfig } from './config/config'; +import defConfig, { PanelsConfig } from './config/config'; import Panel, { PanelProperties } from './model/Panel'; import Panels from './model/Panels'; import PanelsView from './view/PanelsView'; @@ -42,13 +42,8 @@ export default class PanelManager extends Module { * @private */ constructor(em: EditorModel) { - super(em, 'Panels', defaults); + super(em, 'Panels', defConfig()); this.panels = new Panels(this, this.config.defaults!); - for (var name in defaults) { - //@ts-ignore - if (!(name in this.config)) this.config[name] = defaults[name]; - } - return this; } /** diff --git a/packages/core/src/parser/config/config.ts b/packages/core/src/parser/config/config.ts index 058045c70..2074fd721 100644 --- a/packages/core/src/parser/config/config.ts +++ b/packages/core/src/parser/config/config.ts @@ -60,6 +60,11 @@ export interface HTMLParserOptions extends OptionAsDocument { */ keepEmptyTextNodes?: boolean; + /** + * Indicate if or how to detect if the passed HTML string should be parsed as a document. + */ + detectDocument?: boolean | ((html: string) => boolean); + /** * Custom transformer to run before passing the input HTML to the parser. * A common use case might be to sanitize the input string. @@ -105,7 +110,7 @@ export interface ParserConfig { optionsHtml?: HTMLParserOptions; } -const config: ParserConfig = { +const config: () => ParserConfig = () => ({ textTags: ['br', 'b', 'i', 'u', 'a', 'ul', 'ol'], textTypes: ['text', 'textnode', 'comment'], parserCss: undefined, @@ -117,6 +122,6 @@ const config: ParserConfig = { allowUnsafeAttrValue: false, keepEmptyTextNodes: false, }, -}; +}); export default config; diff --git a/packages/core/src/parser/index.ts b/packages/core/src/parser/index.ts index c7c8b6048..e4ec87f41 100644 --- a/packages/core/src/parser/index.ts +++ b/packages/core/src/parser/index.ts @@ -26,7 +26,7 @@ */ import { Module } from '../abstract'; import EditorModel from '../editor/model/Editor'; -import defaults, { HTMLParserOptions, ParserConfig } from './config/config'; +import defConfig, { HTMLParserOptions, ParserConfig } from './config/config'; import ParserCss from './model/ParserCss'; import ParserHtml from './model/ParserHtml'; @@ -35,7 +35,7 @@ export default class ParserModule extends Module; constructor(em: EditorModel) { - super(em, 'Parser', defaults); + super(em, 'Parser', defConfig()); const { config } = this; this.parserCss = ParserCss(em, config); this.parserHtml = ParserHtml(em, config); diff --git a/packages/core/src/parser/model/ParserHtml.ts b/packages/core/src/parser/model/ParserHtml.ts index b0a1779c4..e05c5bd1f 100644 --- a/packages/core/src/parser/model/ParserHtml.ts +++ b/packages/core/src/parser/model/ParserHtml.ts @@ -5,6 +5,7 @@ import EditorModel from '../../editor/model/Editor'; import { HTMLParseResult, HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config'; import BrowserParserHtml from './BrowserParserHtml'; import { doctypeToString } from '../../utils/dom'; +import { isDef } from '../../utils/mixins'; const modelAttrStart = 'data-gjs-'; const event = 'parse:html'; @@ -313,12 +314,16 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo const conf = em?.get('Config') || {}; const res: HTMLParseResult = { html: [] }; const cf = { ...config, ...opts }; - const options = { + const preOptions = { ...config.optionsHtml, // @ts-ignore Support previous `configParser.htmlType` option htmlType: config.optionsHtml?.htmlType || config.htmlType, ...opts, }; + const options = { + ...preOptions, + asDocument: this.__checkAsDocument(str, preOptions), + }; const { preParser, asDocument } = options; const input = isFunction(preParser) ? preParser(str, { editor: em?.getEditor()! }) : str; const parseRes = isFunction(cf.parserHtml) ? cf.parserHtml(input, options) : BrowserParserHtml(input, options); @@ -374,7 +379,7 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo } res.html = resHtml; - em?.trigger(event, { input, output: res }); + em?.trigger(event, { input, output: res, options }); return res; }, @@ -392,6 +397,16 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo toRemove.map((name) => node.removeAttribute(name)); each(nodes, (node) => this.__sanitizeNode(node as HTMLElement, opts)); }, + + __checkAsDocument(str: string, opts: HTMLParserOptions) { + if (isDef(opts.asDocument)) { + return opts.asDocument; + } else if (isFunction(opts.detectDocument)) { + return !!opts.detectDocument(str); + } else if (opts.detectDocument) { + return str.toLowerCase().trim().startsWith(' RichTextEditorConfig = () => ({ stylePrefix: 'rte-', adjustToolbar: true, actions: ['bold', 'italic', 'underline', 'strikethrough', 'link', 'wrap'], custom: false, -}; +}); export default config; diff --git a/packages/core/src/rich_text_editor/index.ts b/packages/core/src/rich_text_editor/index.ts index 48d48f8e6..f7ea78d24 100644 --- a/packages/core/src/rich_text_editor/index.ts +++ b/packages/core/src/rich_text_editor/index.ts @@ -44,7 +44,7 @@ import ComponentView from '../dom_components/view/ComponentView'; import EditorModel from '../editor/model/Editor'; import { createEl, cx, on, removeEl } from '../utils/dom'; import { hasWin, isDef } from '../utils/mixins'; -import defaults, { CustomRTE, RichTextEditorConfig } from './config/config'; +import defConfig, { CustomRTE, RichTextEditorConfig } from './config/config'; import RichTextEditor, { RichTextEditorAction } from './model/RichTextEditor'; import CanvasEvents from '../canvas/types'; import { ComponentsEvents } from '../dom_components/types'; @@ -87,7 +87,7 @@ export default class RichTextEditorModule extends Module SelectorManagerConfig = () => ({ stylePrefix: 'clm-', appendTo: '', selectors: [], @@ -148,6 +148,6 @@ const config: SelectorManagerConfig = { '', componentFirst: false, custom: false, -}; +}); export default config; diff --git a/packages/core/src/selector_manager/index.ts b/packages/core/src/selector_manager/index.ts index 78a363db8..e8905df40 100644 --- a/packages/core/src/selector_manager/index.ts +++ b/packages/core/src/selector_manager/index.ts @@ -76,7 +76,7 @@ import { isString, debounce, isObject, isArray, bindAll } from 'underscore'; import { isComponent, isRule } from '../utils/mixins'; import { Model, Collection, RemoveOptions, SetOptions, Debounced } from '../common'; -import defaults, { SelectorManagerConfig } from './config/config'; +import defConfig, { SelectorManagerConfig } from './config/config'; import Selector from './model/Selector'; import Selectors from './model/Selectors'; import State from './model/State'; @@ -86,7 +86,6 @@ import Component from '../dom_components/model/Component'; import { ItemManagerModule } from '../abstract/Module'; import { StyleModuleParam } from '../style_manager'; import StyleableModel from '../domain_abstract/model/StyleableModel'; -import CssRule from '../css_composer/model/CssRule'; import { ComponentsEvents } from '../dom_components/types'; export type SelectorEvent = 'selector:add' | 'selector:remove' | 'selector:update' | 'selector:state' | 'selector'; @@ -138,7 +137,7 @@ export default class SelectorManager extends ItemManagerModule StorageManagerConfig = () => ({ id: 'gjs-', type: 'local', autosave: true, @@ -103,6 +103,6 @@ const config: StorageManagerConfig = { onLoad: (result) => result, }, }, -}; +}); export default config; diff --git a/packages/core/src/storage_manager/index.ts b/packages/core/src/storage_manager/index.ts index 4db8dbfe6..272d42f45 100644 --- a/packages/core/src/storage_manager/index.ts +++ b/packages/core/src/storage_manager/index.ts @@ -42,7 +42,7 @@ import { isEmpty, isFunction } from 'underscore'; import { Module } from '../abstract'; -import defaults, { StorageManagerConfig } from './config/config'; +import defConfig, { StorageManagerConfig } from './config/config'; import LocalStorage from './model/LocalStorage'; import RemoteStorage from './model/RemoteStorage'; import EditorModel from '../editor/model/Editor'; @@ -65,7 +65,7 @@ export default class StorageManager extends Module< events = StorageEvents; constructor(em: EditorModel) { - super(em, 'StorageManager', defaults); + super(em, 'StorageManager', defConfig()); const { config } = this; if (config._disable) config.type = undefined; this.storages = {}; diff --git a/packages/core/src/style_manager/config/config.ts b/packages/core/src/style_manager/config/config.ts index 5dffb171d..f8462567c 100644 --- a/packages/core/src/style_manager/config/config.ts +++ b/packages/core/src/style_manager/config/config.ts @@ -66,7 +66,7 @@ export interface StyleManagerConfig { pStylePrefix?: string; } -export default { +const config: () => StyleManagerConfig = () => ({ sectors: [ { name: 'General', @@ -128,4 +128,6 @@ export default { showComputed: true, clearProperties: true, avoidComputed: ['width', 'height'], -} as StyleManagerConfig; +}); + +export default config; diff --git a/packages/core/src/style_manager/index.ts b/packages/core/src/style_manager/index.ts index 331568607..998394775 100644 --- a/packages/core/src/style_manager/index.ts +++ b/packages/core/src/style_manager/index.ts @@ -66,7 +66,7 @@ import { isUndefined, isArray, isString, debounce, bindAll } from 'underscore'; import { isComponent } from '../utils/mixins'; import { AddOptions, Debounced, Model } from '../common'; -import defaults, { StyleManagerConfig } from './config/config'; +import defConfig, { StyleManagerConfig } from './config/config'; import Sector, { SectorProperties } from './model/Sector'; import Sectors from './model/Sectors'; import Properties from './model/Properties'; @@ -157,7 +157,7 @@ export default class StyleManager extends ItemManagerModule< * @private */ constructor(em: EditorModel) { - super(em, 'StyleManager', new Sectors([], { em }), stylesEvents, defaults); + super(em, 'StyleManager', new Sectors([], { em }), stylesEvents, defConfig()); bindAll(this, '__clearStateTarget'); const c = this.config; const ppfx = c.pStylePrefix; diff --git a/packages/core/src/trait_manager/config/config.ts b/packages/core/src/trait_manager/config/config.ts index 1b403c4f3..bd149d1ea 100644 --- a/packages/core/src/trait_manager/config/config.ts +++ b/packages/core/src/trait_manager/config/config.ts @@ -22,11 +22,11 @@ export interface TraitManagerConfig { optionsTarget?: Record[]; } -const config: TraitManagerConfig = { +const config: () => TraitManagerConfig = () => ({ stylePrefix: 'trt-', appendTo: '', optionsTarget: [{ value: false }, { value: '_blank' }], custom: false, -}; +}); export default config; diff --git a/packages/core/src/trait_manager/index.ts b/packages/core/src/trait_manager/index.ts index 69193fa3e..31a4a3cc9 100644 --- a/packages/core/src/trait_manager/index.ts +++ b/packages/core/src/trait_manager/index.ts @@ -33,7 +33,7 @@ import { Module } from '../abstract'; import { Model } from '../common'; import Component from '../dom_components/model/Component'; import EditorModel from '../editor/model/Editor'; -import defaults from './config/config'; +import defConfig from './config/config'; import { CustomTrait, TraitCustomData, @@ -81,7 +81,7 @@ export default class TraitManager extends Module { * @private */ constructor(em: EditorModel) { - super(em, 'TraitManager', defaults as any); + super(em, 'TraitManager', defConfig() as any); const { state, config, events } = this; const ppfx = config.pStylePrefix; ppfx && (config.stylePrefix = `${ppfx}${config.stylePrefix}`); diff --git a/packages/core/src/undo_manager/config.ts b/packages/core/src/undo_manager/config.ts index 7be4ecf74..b86804223 100644 --- a/packages/core/src/undo_manager/config.ts +++ b/packages/core/src/undo_manager/config.ts @@ -11,9 +11,9 @@ export interface UndoManagerConfig { trackSelection?: boolean; } -const config: UndoManagerConfig = { +const config: () => UndoManagerConfig = () => ({ maximumStackLength: 500, trackSelection: true, -}; +}); export default config; diff --git a/packages/core/src/undo_manager/index.ts b/packages/core/src/undo_manager/index.ts index 598891365..7e60aa5c0 100644 --- a/packages/core/src/undo_manager/index.ts +++ b/packages/core/src/undo_manager/index.ts @@ -28,7 +28,7 @@ import UndoManager from 'backbone-undo'; import { isArray, isBoolean, isEmpty, unique, times } from 'underscore'; import { Module } from '../abstract'; import EditorModel from '../editor/model/Editor'; -import defaults, { UndoManagerConfig } from './config'; +import defConfig, { UndoManagerConfig } from './config'; export interface UndoGroup { index: number; @@ -47,7 +47,7 @@ export default class UndoManagerModule extends Module { + em.on(CanvasEvents.frameLoad, ({ el, view }) => { + // this seems to fix the issue of the loop + el.onload = null; + }); +}; + export function waitEditorEvent(em: Editor | EditorModel, event: string) { return new Promise((resolve) => em.once(event, resolve)); } diff --git a/packages/core/test/specs/dom_components/index.ts b/packages/core/test/specs/dom_components/index.ts index 73dd95876..152139b0e 100644 --- a/packages/core/test/specs/dom_components/index.ts +++ b/packages/core/test/specs/dom_components/index.ts @@ -319,21 +319,21 @@ describe('DOM Components', () => { describe('Custom components with styles', () => { test('canMove returns false if source is not provided', () => { - const target = obj.addComponent({ type: 'div' }) as Component; + const target = obj.addComponent({ tagName: 'div' }) as Component; const result = obj.canMove(target); expect(result.result).toBe(false); expect(result.reason).toBe(CanMoveReason.InvalidSource); }); test('canMove returns false if target is not provided', () => { - const source = obj.addComponent({ type: 'div' }) as Component; + const source = obj.addComponent({ tagName: 'div' }) as Component; const result = obj.canMove(undefined as any, source); expect(result.result).toBe(false); expect(result.reason).toBe(CanMoveReason.InvalidSource); }); test('canMove returns false when source and target have the same main symbol', () => { - const component = obj.addComponent({ type: 'div' }) as Component; + const component = obj.addComponent({ tagName: 'div' }) as Component; const mainSymbol = obj.addSymbol(component) as Component; const target = obj.addSymbol(mainSymbol) as Component; const source = obj.addSymbol(mainSymbol) as Component; @@ -356,31 +356,31 @@ describe('DOM Components', () => { }); test('canMove returns false when source is not draggable in the target', () => { - const target = obj.addComponent({ type: 'div', droppable: true }) as Component; - const source = obj.addComponent({ type: 'span', draggable: false }) as Component; + const target = obj.addComponent({ tagName: 'div', droppable: true }) as Component; + const source = obj.addComponent({ tagName: 'span', draggable: false }) as Component; const result = obj.canMove(target, source); expect(result.result).toBe(false); expect(result.reason).toBe(CanMoveReason.SourceReject); }); test('canMove returns false when target does not accept the source', () => { - const target = obj.addComponent({ type: 'div', droppable: false }) as Component; - const source = obj.addComponent({ type: 'span', draggable: true }) as Component; + const target = obj.addComponent({ tagName: 'div', droppable: false }) as Component; + const source = obj.addComponent({ tagName: 'span', draggable: true }) as Component; const result = obj.canMove(target, source); expect(result.result).toBe(false); expect(result.reason).toBe(CanMoveReason.TargetReject); }); test('canMove returns true when source is draggable and target is droppable', () => { - const target = obj.addComponent({ type: 'div', droppable: true }) as Component; - const source = obj.addComponent({ type: 'span', draggable: true }) as Component; + const target = obj.addComponent({ tagName: 'div', droppable: true }) as Component; + const source = obj.addComponent({ tagName: 'span', draggable: true }) as Component; const result = obj.canMove(target, source); expect(result.result).toBe(true); }); test('canMove returns false when target is inside the source', () => { - const source = obj.addComponent({ type: 'div' }) as Component; - const target = source.append({ type: 'span' })[0]; + const source = obj.addComponent({ tagName: 'div' }) as Component; + const target = source.append({ tagName: 'span' })[0]; const result = obj.canMove(target, source); expect(result.result).toBe(false); expect(result.reason).toBe(CanMoveReason.TargetReject); @@ -491,6 +491,33 @@ describe('DOM Components', () => { expect(newRoot.toHTML()).toBe(flattenHTML(docHtml)); }); + + test('load projectData containing HTML document, without document detect', () => { + editor.loadProjectData({ + pages: [{ component: docHtml }], + }); + const newRoot = editor.getWrapper()!; + const { head, doctype } = newRoot; + + // Components from the head are moved inside the body + expect(head.components().length).toBe(0); + expect(doctype).toBe(''); + expect(newRoot.components().length).toBe(2); + }); + + test('load projectData containing HTML document, with document detect', () => { + editor.Parser.getConfig().optionsHtml!.detectDocument = true; + editor.loadProjectData({ + pages: [{ component: docHtml }], + }); + const newRoot = editor.getWrapper()!; + + const { head, doctype } = newRoot; + expect(head.components().length).toBe(1); + expect(doctype).toBe(''); + + expect(newRoot.toHTML()).toBe(flattenHTML(docHtml)); + }); }); }); }); diff --git a/packages/core/test/specs/grapesjs/index.ts b/packages/core/test/specs/grapesjs/index.ts index f1905e03d..2d7113260 100644 --- a/packages/core/test/specs/grapesjs/index.ts +++ b/packages/core/test/specs/grapesjs/index.ts @@ -1,6 +1,8 @@ import grapesjs, { Component, Editor, usePlugin } from '../../../src'; import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper'; +import { EditorConfig } from '../../../src/editor/config/config'; import type { Plugin } from '../../../src/plugin_manager'; +import { fixJsDom, fixJsDomIframe } from '../../common'; type TestPlugin = Plugin<{ cVal: string }>; @@ -24,6 +26,16 @@ describe('GrapesJS', () => { }, }; + const initTestEditor = (config: Partial) => { + const editor = grapesjs.init({ + ...config, + plugins: [fixJsDom, ...(config.plugins || [])], + }); + fixJsDomIframe(editor.getModel().shallow); + + return editor; + }; + beforeAll(() => { editorName = 'editor-fixture'; }); @@ -86,23 +98,33 @@ describe('GrapesJS', () => { expect(editor.getStyle().length).toEqual(0); }); - test.skip('Editor canvas baseCSS can be overwritten', () => { + test('Editor canvas baseCSS can be overwritten', (done) => { config.components = htmlString; config.baseCss = '#wrapper { background-color: #eee; }'; config.protectedCss = ''; - const editor = grapesjs.init(config); - const body = editor.Canvas.getBody(); - expect(body.outerHTML).toContain(config.baseCss); - expect(body.outerHTML.replace(/\s+/g, ' ')).not.toContain('body { margin: 0;'); + const editor = initTestEditor({ + ...config, + components: htmlString, + baseCss: '#wrapper { background-color: #eee; }', + protectedCss: '', + }); + editor.onReady(() => { + const body = editor.Canvas.getBody(); + expect(body.outerHTML).toContain(config.baseCss); + expect(body.outerHTML.replace(/\s+/g, ' ')).not.toContain('body { margin: 0;'); + done(); + }); }); - test.skip('Editor canvas baseCSS defaults to sensible values if not defined', () => { + test('Editor canvas baseCSS defaults to sensible values if not defined', (done) => { config.components = htmlString; config.protectedCss = ''; - grapesjs.init(config); - expect(window.frames[0].document.documentElement.outerHTML.replace(/\s+/g, ' ')).toContain( - 'body { background-color: #fff', - ); + const editor = initTestEditor(config); + editor.onReady(() => { + const htmlEl = editor.Canvas.getDocument().documentElement; + expect(htmlEl.outerHTML.replace(/\s+/g, ' ')).toContain('body { background-color: #fff'); + done(); + }); }); test('Init editor with html', () => { @@ -459,25 +481,32 @@ describe('GrapesJS', () => { }); }); - describe.skip('Component selection', () => { + describe('Component selection', () => { let editor: Editor; let wrapper: ComponentWrapper; let el1: Component; let el2: Component; let el3: Component; - beforeEach(() => { - config.storageManager = { type: 0 }; - config.components = `
-
-
-
-
`; - editor = grapesjs.init(config); + beforeEach((done) => { + editor = grapesjs.init({ + container: `#${editorName}`, + storageManager: false, + plugins: [fixJsDom], + components: `
+
+
+
+
`, + }); + fixJsDomIframe(editor.getModel().shallow); wrapper = editor.DomComponents.getWrapper()!; - el1 = wrapper.find('#el1')[0]; - el2 = wrapper.find('#el2')[0]; - el3 = wrapper.find('#el3')[0]; + editor.onReady(() => { + el1 = wrapper.find('#el1')[0]; + el2 = wrapper.find('#el2')[0]; + el3 = wrapper.find('#el3')[0]; + done(); + }); }); test('Select a single component', () => { diff --git a/packages/core/test/specs/navigator/view/ItemView.js b/packages/core/test/specs/navigator/view/ItemView.ts similarity index 72% rename from packages/core/test/specs/navigator/view/ItemView.js rename to packages/core/test/specs/navigator/view/ItemView.ts index ed025f341..09ee65c88 100644 --- a/packages/core/test/specs/navigator/view/ItemView.js +++ b/packages/core/test/specs/navigator/view/ItemView.ts @@ -1,11 +1,11 @@ -import ItemView from 'navigator/view/ItemView'; -import config from 'navigator/config/config'; -import EditorModel from 'editor/model/Editor'; +import defConfig from '../../../../src/navigator/config/config'; +import EditorModel from '../../../../src/editor/model/Editor'; +import ItemView from '../../../../src/navigator/view/ItemView'; describe('ItemView', () => { - let itemView; + let itemView: ItemView; - const isVisible = (itemView) => { + const isVisible = (itemView: ItemView) => { return itemView.module.isVisible(itemView.model); }; @@ -16,8 +16,8 @@ describe('ItemView', () => { itemView = new ItemView({ model: new defCmp({}, { em }), module: em.get('LayerManager'), - config: { ...config, em }, - }); + config: { ...defConfig(), em }, + } as any); }); describe('.isVisible', () => { From 67f1d79fe4b361f68e80f6099cb4dc844d9156b9 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Mon, 7 Oct 2024 10:42:53 +0400 Subject: [PATCH 3/6] Fixing skipped tests (#6195) Fixing skipped tests in grapesjs test cases --- .../core/test/specs/css_composer/index.ts | 1 - packages/core/test/specs/grapesjs/index.ts | 79 +++++++++---------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/packages/core/test/specs/css_composer/index.ts b/packages/core/test/specs/css_composer/index.ts index 45b6b48c5..1714a71d9 100644 --- a/packages/core/test/specs/css_composer/index.ts +++ b/packages/core/test/specs/css_composer/index.ts @@ -428,7 +428,6 @@ describe('Css Composer', () => { `; const result = obj.addCollection(cssRule); const [rule1, rule2, rule3] = result; - console.log({ result: JSON.parse(JSON.stringify(rule1)) }); expect(result.length).toEqual(3); expect(obj.getAll().length).toEqual(3); diff --git a/packages/core/test/specs/grapesjs/index.ts b/packages/core/test/specs/grapesjs/index.ts index 2d7113260..f080edec5 100644 --- a/packages/core/test/specs/grapesjs/index.ts +++ b/packages/core/test/specs/grapesjs/index.ts @@ -2,7 +2,8 @@ import grapesjs, { Component, Editor, usePlugin } from '../../../src'; import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper'; import { EditorConfig } from '../../../src/editor/config/config'; import type { Plugin } from '../../../src/plugin_manager'; -import { fixJsDom, fixJsDomIframe } from '../../common'; +import { StorageManagerConfig } from '../../../src/storage_manager/config/config'; +import { fixJsDom, fixJsDomIframe, waitEditorEvent } from '../../common'; type TestPlugin = Plugin<{ cVal: string }>; @@ -11,7 +12,7 @@ describe('GrapesJS', () => { let fixture: HTMLElement; let editorName = ''; let htmlString = ''; - let config: any; + let config: Partial; let cssString = ''; let documentEl = ''; @@ -49,9 +50,9 @@ describe('GrapesJS', () => { config = { container: '#' + editorName, storageManager: { - autoload: 0, - autosave: 0, - type: 0, + autoload: false, + autosave: false, + type: '', }, }; document.body.innerHTML = `
`; @@ -144,8 +145,8 @@ describe('GrapesJS', () => { }); test('Init editor from element', () => { - config.fromElement = 1; - config.storageManager = { type: 0 }; + config.fromElement = true; + config.storageManager = false; fixture.innerHTML = documentEl; const editor = grapesjs.init(config); const html = editor.getHtml(); @@ -160,8 +161,8 @@ describe('GrapesJS', () => { }); test('Init editor from element with multiple font-face at-rules', () => { - config.fromElement = 1; - config.storageManager = { type: 0 }; + config.fromElement = true; + config.storageManager = false; fixture.innerHTML = ` ' + htmlString; - config.fromElement = 1; - config.storageManager = { type: 0 }; + config.fromElement = true; + config.storageManager = false; fixture.innerHTML = documentEl; const editor = grapesjs.init(config); const css = editor.getCss({ keepUnusedStyles: true }); @@ -311,10 +312,10 @@ describe('GrapesJS', () => { }); test('Keep unused css classes/selectors option for init method', () => { - config.fromElement = 1; - config.storageManager = { type: 0 }; + config.fromElement = true; + config.storageManager = false; fixture.innerHTML = documentEl; - const editor = grapesjs.init({ ...config, keepUnusedStyles: 1 }); + const editor = grapesjs.init({ ...config, keepUnusedStyles: true }); const css = editor.getCss(); const protCss = editor.getConfig().protectedCss; expect(editor.getStyle().length).toEqual(2); @@ -322,25 +323,21 @@ describe('GrapesJS', () => { }); describe('Plugins', () => { - test.skip('Adds new storage as plugin and store data there', (done) => { - const pluginName = storageId + '-p2'; - grapesjs.plugins.add(pluginName, (e) => e.StorageManager.add(storageId, storageMock)); - config.storageManager.type = storageId; - config.plugins = [pluginName]; - const editor = grapesjs.init(config); + test('Adds new storage as plugin and store data there', async () => { + (config.storageManager as StorageManagerConfig).type = storageId; + config.plugins = [(e) => e.StorageManager.add(storageId, storageMock)]; + const editor = initTestEditor(config); editor.setComponents(htmlString); - editor.store(() => { - editor.load((data: any) => { - expect(data.html).toEqual(htmlString); - done(); - }); - }); + const projectData = editor.getProjectData(); + await editor.store(); + const data = await editor.load(); + expect(data).toEqual(projectData); }); - test.skip('Adds a new storage and fetch correctly data from it', async () => { + test('Adds a new storage and fetch correctly data from it', async () => { fixture.innerHTML = documentEl; const styleResult = { color: 'white', display: 'block' }; - const style = [ + const styles = [ { selectors: [{ name: 'sclass1' }], style: { color: 'green' }, @@ -355,21 +352,19 @@ describe('GrapesJS', () => { }, ]; storage = { - css: '* { box-sizing: border-box; } body {margin: 0;}', - styles: JSON.stringify(style), + styles, pages: [{}], }; - - const pluginName = storageId + '-p'; - grapesjs.plugins.add(pluginName, (e) => e.StorageManager.add(storageId, storageMock)); config.fromElement = true; - config.storageManager.type = storageId; - config.plugins = [pluginName]; - config.storageManager.autoload = 1; - const editor = grapesjs.init(config); - const cc = editor.CssComposer; - expect(cc.getAll().length).toEqual(style.length); - expect(cc.getClassRule('test2')!.getStyle()).toEqual(styleResult); + config.plugins = [(e) => e.StorageManager.add(storageId, storageMock)]; + const configStorage = config.storageManager as StorageManagerConfig; + configStorage.type = storageId; + configStorage.autoload = true; + const editor = initTestEditor(config); + await waitEditorEvent(editor, 'load'); + const { Css } = editor; + expect(Css.getAll().length).toEqual(styles.length); + expect(Css.getClassRule('test2')!.getStyle()).toEqual(styleResult); }); test('Execute plugins with custom options', () => { From 025fd7dcb5dd47646ffe419b674f6e964dd1902f Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Mon, 7 Oct 2024 11:06:14 +0300 Subject: [PATCH 4/6] Fix dragging (absolute dragging mode) (#6192) * Fix dragging in absolute dragging mode * Remove caching temp models --------- Co-authored-by: Artur Arseniev --- packages/core/src/utils/Droppable.ts | 7 ++-- .../utils/sorter/CanvasNewComponentNode.ts | 40 +------------------ 2 files changed, 6 insertions(+), 41 deletions(-) diff --git a/packages/core/src/utils/Droppable.ts b/packages/core/src/utils/Droppable.ts index 94399aa02..c38396064 100644 --- a/packages/core/src/utils/Droppable.ts +++ b/packages/core/src/utils/Droppable.ts @@ -29,6 +29,7 @@ export default class Droppable { dragStop?: DragStop; draggedNode?: CanvasNewComponentNode; sorter!: ComponentSorter; + setAbsoluteDragContent?: (cnt: any) => any; constructor(em: EditorModel, rootEl?: HTMLElement) { this.em = em; @@ -123,7 +124,6 @@ export default class Droppable { // any not empty element let content = dragSourceOrigin?.content || '
'; let dragStop: DragStop; - let dragSource; em.stopDefault(); // Select the right drag provider @@ -137,7 +137,7 @@ export default class Droppable { target, onEnd: (ev: any, dragger: any, { cancelled }: any) => { let comp; - if (!cancelled) { + if (!cancelled && !!content) { comp = wrapper.append(content)[0]; const canvasOffset = canvas.getOffset(); const { top, left, position } = target.getStyle() as ObjectStrings; @@ -156,7 +156,7 @@ export default class Droppable { }, }); dragStop = (cancel?: boolean) => dragger.stop(ev, { cancel }); - dragSource = (cnt: any) => (content = cnt); + this.setAbsoluteDragContent = (cnt: any) => (content = cnt); } else { const sorter = new utils.ComponentSorter({ em, @@ -255,6 +255,7 @@ export default class Droppable { if (this.draggedNode) { this.draggedNode.content = content; } + this.setAbsoluteDragContent?.(content); this.endDrop(!content, ev); } diff --git a/packages/core/src/utils/sorter/CanvasNewComponentNode.ts b/packages/core/src/utils/sorter/CanvasNewComponentNode.ts index 045538c97..68582e0dc 100644 --- a/packages/core/src/utils/sorter/CanvasNewComponentNode.ts +++ b/packages/core/src/utils/sorter/CanvasNewComponentNode.ts @@ -8,40 +8,6 @@ import { isComponent } from '../mixins'; type CanMoveSource = Component | ContentType; export default class CanvasNewComponentNode extends CanvasComponentNode { - /** - * A cache of shallow editor models stored in the source node. - * This is a map where each content item is associated with its corresponding shallow wrapper model. - */ - private _cachedShallowModels: Map = new Map(); - - /** - * Ensures the shallow editor model for the given contentItem is cached in the source node. - * If not cached, retrieves it from the shallow wrapper and stores it in the cache. - * @param contentItem - The content item to retrieve or cache. - * @returns {Component | null} - The shallow wrapper model, either cached or retrieved. - */ - cacheSrcModelForContent(contentItem: ContentElement | Component): Component | undefined { - if (isComponent(contentItem)) { - return contentItem; - } - - if (this._cachedShallowModels.has(contentItem)) { - return this._cachedShallowModels.get(contentItem)!; - } - - const wrapper = this.model.em.Components.getShallowWrapper(); - const srcModel = wrapper?.append(contentItem)[0]; - // Replace getEl as the element would be removed in the shallow wrapper after 100ms - const el = srcModel?.getEl(); - srcModel!.getEl = () => el; - - if (srcModel) { - this._cachedShallowModels.set(contentItem, srcModel); - } - - return srcModel; - } - canMove(source: CanvasNewComponentNode, index: number): boolean { const realIndex = this.getRealIndex(index); const { model: symbolModel, content, dragDef } = source._dragSource; @@ -51,13 +17,11 @@ export default class CanvasNewComponentNode extends CanvasComponentNode { if (Array.isArray(sourceContent)) { return ( - sourceContent.every((contentItem, i) => - this.canMoveSingleContent(source.cacheSrcModelForContent(contentItem)!, realIndex + i), - ) && canMoveSymbol + canMoveSymbol && sourceContent.every((contentItem, i) => this.canMoveSingleContent(contentItem, realIndex + i)) ); } - return this.canMoveSingleContent(source.cacheSrcModelForContent(sourceContent)!, realIndex) && canMoveSymbol; + return canMoveSymbol && this.canMoveSingleContent(sourceContent, realIndex); } private canMoveSingleContent(contentItem: ContentElement | Component, index: number): boolean { From fc30655002ad8f42e93f5b271d1ac93fa4ad8f02 Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Tue, 8 Oct 2024 09:31:47 +0300 Subject: [PATCH 5/6] Fix placeholder (#6197) * Fix dragging in absolute dragging mode * Remove caching temp models * Fix multiple components dropping position * Fix placeholder position * Remove spots if no valid target exists --------- Co-authored-by: Artur Arseniev --- packages/core/src/utils/sorter/ComponentSorter.ts | 12 +++++++----- .../core/src/utils/sorter/DropLocationDeterminer.ts | 1 + packages/core/src/utils/sorter/RateLimiter.ts | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/sorter/ComponentSorter.ts b/packages/core/src/utils/sorter/ComponentSorter.ts index b71e9313c..833ee245e 100644 --- a/packages/core/src/utils/sorter/ComponentSorter.ts +++ b/packages/core/src/utils/sorter/ComponentSorter.ts @@ -129,8 +129,9 @@ export default class ComponentSorter extends return sourceNodes.reduce((addedNodes, sourceNode) => { if (!targetNode.canMove(sourceNode, index)) return addedNodes; if (this.isPositionChanged(targetNode, sourceNode, index)) { - const addedNode = this.moveNode(targetNode, sourceNode, index); + const { index: lastIndex, addedNode } = this.moveNode(targetNode, sourceNode, index); addedNodes.push(addedNode); + index = lastIndex; } index++; // Increment the index return addedNodes; @@ -165,9 +166,9 @@ export default class ComponentSorter extends * @param targetNode - The node where the source node will be moved. * @param sourceNode - The node being moved. * @param index - The index at which to move the source node. - * @returns The node that was moved and added, or null if it couldn't be moved. + * @returns An object containing the added node and its new index, or null if it couldn't be moved. */ - private moveNode(targetNode: NodeType, sourceNode: NodeType, index: number): NodeType { + private moveNode(targetNode: NodeType, sourceNode: NodeType, index: number) { const parent = sourceNode.getParent(); if (parent) { const initialSourceIndex = parent.indexOfChild(sourceNode); @@ -180,7 +181,7 @@ export default class ComponentSorter extends const addedNode = targetNode.addChildAt(sourceNode, index, { action: 'move-component' }) as NodeType; this.triggerEndMoveEvent(addedNode); - return addedNode; + return { addedNode, index }; } /** @@ -212,8 +213,10 @@ export default class ComponentSorter extends private onTargetChange = (oldTargetNode: NodeType | undefined, newTargetNode: NodeType | undefined) => { oldTargetNode?.restNodeState(); + const { Canvas } = this.em; if (!newTargetNode) { this.placeholder.hide(); + Canvas.removeSpots(spotTarget); return; } newTargetNode?.setSelectedParentState(); @@ -226,7 +229,6 @@ export default class ComponentSorter extends this.placeholder.show(); } - const { Canvas } = this.em; const { Select, Hover, Spacing } = CanvasSpotBuiltInTypes; [Select, Hover, Spacing].forEach((type) => Canvas.removeSpots({ type })); Canvas.addSpot({ ...spotTarget, component: newTargetNode.model }); diff --git a/packages/core/src/utils/sorter/DropLocationDeterminer.ts b/packages/core/src/utils/sorter/DropLocationDeterminer.ts index c8cccc0ee..b8466a6af 100644 --- a/packages/core/src/utils/sorter/DropLocationDeterminer.ts +++ b/packages/core/src/utils/sorter/DropLocationDeterminer.ts @@ -126,6 +126,7 @@ export class DropLocationDeterminer> ext if (!targetNode) { this.triggerLegacyOnMoveCallback(mouseEvent, 0); this.triggerMoveEvent(mouseX, mouseY); + this.restLastMoveData(); return; } diff --git a/packages/core/src/utils/sorter/RateLimiter.ts b/packages/core/src/utils/sorter/RateLimiter.ts index d03edb1d4..4b5eeacd2 100644 --- a/packages/core/src/utils/sorter/RateLimiter.ts +++ b/packages/core/src/utils/sorter/RateLimiter.ts @@ -27,5 +27,6 @@ export class RateLimiter { clearTimeout(this.timeout); this.timeout = null; } + delete this.lastArgs; } } From 5bad337f56ee1d76fa93635e04a883aa07adfa90 Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Tue, 8 Oct 2024 20:56:41 +0300 Subject: [PATCH 6/6] Add dropzone (#6200) * Fix issue with very small components * Add drop area calculation to tree nodes * Add helper methods to dimension class * Add drop zone to the sorter * Change minimum undroppable dimensions --- packages/core/src/canvas/index.ts | 2 +- .../src/utils/sorter/CanvasComponentNode.ts | 5 ++ packages/core/src/utils/sorter/Dimension.ts | 44 ++++++++++- .../utils/sorter/DropLocationDeterminer.ts | 74 +++++++++++++------ .../core/src/utils/sorter/PlaceholderClass.ts | 2 +- .../core/src/utils/sorter/SortableTreeNode.ts | 23 ++++++ packages/core/src/utils/sorter/types.ts | 6 ++ 7 files changed, 130 insertions(+), 26 deletions(-) diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index c3c84fa1f..d4f3d841c 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -499,7 +499,7 @@ export default class CanvasModule extends Module { * @return {Object} * @private */ - getMouseRelativeCanvas(ev: MouseEvent, opts: any) { + getMouseRelativeCanvas(ev: MouseEvent | { clientX: number; clientY: number }, opts: any) { const zoom = this.getZoomDecimal(); const { top = 0, left = 0 } = this.getCanvasView().getPosition(opts) ?? {}; diff --git a/packages/core/src/utils/sorter/CanvasComponentNode.ts b/packages/core/src/utils/sorter/CanvasComponentNode.ts index 087d302e7..2495e75f2 100644 --- a/packages/core/src/utils/sorter/CanvasComponentNode.ts +++ b/packages/core/src/utils/sorter/CanvasComponentNode.ts @@ -1,6 +1,11 @@ import { BaseComponentNode } from './BaseComponentNode'; export default class CanvasComponentNode extends BaseComponentNode { + protected _dropAreaConfig = { + ratio: 0.8, + minUndroppableDimension: 1, // In px + maxUndroppableDimension: 15, // In px + }; /** * Get the associated view of this component. * @returns The view associated with the component, or undefined if none. diff --git a/packages/core/src/utils/sorter/Dimension.ts b/packages/core/src/utils/sorter/Dimension.ts index 0fbfcf9d3..dc8b7e284 100644 --- a/packages/core/src/utils/sorter/Dimension.ts +++ b/packages/core/src/utils/sorter/Dimension.ts @@ -1,5 +1,5 @@ import CanvasModule from '../../canvas'; -import { Placement } from './types'; +import { Placement, DroppableZoneConfig } from './types'; /** * A class representing dimensions of an element, including position, size, offsets, and other metadata. @@ -113,4 +113,46 @@ export default class Dimension { dir: this.dir, }); } + + public getDropArea(config: DroppableZoneConfig): Dimension { + const dropZone = this.clone(); + // Adjust width + const { newSize: newWidth, newPosition: newLeft } = this.adjustDropDimension(this.width, this.left, config); + dropZone.width = newWidth; + dropZone.left = newLeft; + + // Adjust height + const { newSize: newHeight, newPosition: newTop } = this.adjustDropDimension(this.height, this.top, config); + dropZone.height = newHeight; + dropZone.top = newTop; + + return dropZone; + } + + private adjustDropDimension( + size: number, + position: number, + config: DroppableZoneConfig, + ): { newSize: number; newPosition: number } { + const { ratio, minUndroppableDimension: minUnDroppableDimension, maxUndroppableDimension } = config; + + let undroppableDimension = (size * (1 - ratio)) / 2; + undroppableDimension = Math.max(undroppableDimension, minUnDroppableDimension); + undroppableDimension = Math.min(undroppableDimension, maxUndroppableDimension); + const newSize = size - undroppableDimension * 2; + const newPosition = position + undroppableDimension; + + return { newSize, newPosition }; + } + + /** + * Checks if the given coordinates are within the bounds of this dimension instance. + * + * @param {number} x - The X coordinate to check. + * @param {number} y - The Y coordinate to check. + * @returns {boolean} - True if the coordinates are within bounds, otherwise false. + */ + public isWithinBounds(x: number, y: number): boolean { + return x >= this.left && x <= this.left + this.width && y >= this.top && y <= this.top + this.height; + } } diff --git a/packages/core/src/utils/sorter/DropLocationDeterminer.ts b/packages/core/src/utils/sorter/DropLocationDeterminer.ts index b8466a6af..736573704 100644 --- a/packages/core/src/utils/sorter/DropLocationDeterminer.ts +++ b/packages/core/src/utils/sorter/DropLocationDeterminer.ts @@ -116,8 +116,10 @@ export class DropLocationDeterminer> ext const { targetNode: lastTargetNode } = this.lastMoveData; this.eventHandlers.onMouseMove?.(mouseEvent); - const { mouseXRelativeToContainer: mouseX, mouseYRelativeToContainer: mouseY } = - this.getMousePositionRelativeToContainer(mouseEvent); + const { mouseXRelative: mouseX, mouseYRelative: mouseY } = this.getMousePositionRelativeToContainer( + mouseEvent.clientX, + mouseEvent.clientY, + ); const targetNode = this.getTargetNode(mouseEvent); const targetChanged = !targetNode?.equals(lastTargetNode); if (targetChanged) { @@ -257,6 +259,10 @@ export class DropLocationDeterminer> ext */ private getTargetNode(mouseEvent: MouseEvent): NodeType | undefined { this.cacheContainerPosition(this.containerContext.container); + const { mouseXRelative, mouseYRelative } = this.getMousePositionRelativeToContainer( + mouseEvent.clientX, + mouseEvent.clientY, + ); // Get the element under the mouse const mouseTargetEl = this.getMouseTargetElement(mouseEvent); @@ -268,10 +274,10 @@ export class DropLocationDeterminer> ext let hoveredNode = this.getOrCreateHoveredNode(hoveredModel); // Get the drop position index based on the mouse position - const { index } = this.getDropPosition(hoveredNode, mouseEvent.clientX, mouseEvent.clientY); + const { index } = this.getDropPosition(hoveredNode, mouseXRelative, mouseYRelative); // Determine the valid target node (or its valid parent) - let targetNode = this.getValidParent(hoveredNode, index, mouseEvent.clientX, mouseEvent.clientY); + let targetNode = this.getValidParent(hoveredNode, index, mouseXRelative, mouseYRelative); return this.getOrReuseTargetNode(targetNode); } @@ -389,21 +395,44 @@ export class DropLocationDeterminer> ext private getValidParent(targetNode: NodeType, index: number, mouseX: number, mouseY: number): NodeType | undefined { if (!targetNode) return; - const positionNotChanged = targetNode.equals(this.lastMoveData.targetNode) && index === this.lastMoveData.index; - if (positionNotChanged) return targetNode; + + const lastTargetNode = this.lastMoveData.targetNode; + const targetNotChanged = targetNode.equals(lastTargetNode); + targetNode.nodeDimensions = targetNotChanged ? lastTargetNode.nodeDimensions! : this.getDim(targetNode.element!); + if (!targetNode.isWithinDropBounds(mouseX, mouseY)) { + return this.handleParentTraversal(targetNode, mouseX, mouseY); + } + + const positionNotChanged = targetNotChanged && index === this.lastMoveData.index; + if (positionNotChanged) return lastTargetNode; const canMove = this.sourceNodes.some((node) => targetNode.canMove(node, index)); this.triggerDragValidation(canMove, targetNode); if (canMove) return targetNode; + return this.handleParentTraversal(targetNode, mouseX, mouseY); + } + + private handleParentTraversal(targetNode: NodeType, mouseX: number, mouseY: number): NodeType | undefined { const parent = targetNode.getParent() as NodeType; if (!parent) return; + const indexInParent = this.getIndexInParent(parent, targetNode, targetNode.nodeDimensions!, mouseX, mouseY); + return this.getValidParent(parent, indexInParent, mouseX, mouseY); + } + + private getIndexInParent( + parent: NodeType, + targetNode: NodeType, + nodeDimensions: Dimension, + mouseX: number, + mouseY: number, + ) { let indexInParent = parent?.indexOfChild(targetNode); - const nodeDimensions = this.getDim(targetNode.element!); nodeDimensions.dir = this.getDirection(targetNode.element!, parent.element!); + indexInParent = indexInParent + (nodeDimensions.determinePlacement(mouseX, mouseY) == 'after' ? 1 : 0); - return this.getValidParent(parent, indexInParent, mouseX, mouseY); + return indexInParent; } private triggerDragValidation(canMove: boolean, targetNode: NodeType) { @@ -488,27 +517,26 @@ export class DropLocationDeterminer> ext /** * Gets the mouse position relative to the container, adjusting for scroll and canvas relative options. * - * @param {MouseEvent} mouseEvent - The current mouse event. * @return {{ mouseXRelativeToContainer: number, mouseYRelativeToContainer: number }} - The mouse X and Y positions relative to the container. * @private */ - private getMousePositionRelativeToContainer(mouseEvent: MouseEvent): { - mouseXRelativeToContainer: number; - mouseYRelativeToContainer: number; + private getMousePositionRelativeToContainer( + mouseX: number, + mouseY: number, + ): { + mouseXRelative: number; + mouseYRelative: number; } { - const { em } = this; - let mouseYRelativeToContainer = - mouseEvent.pageY - this.containerOffset.top + this.containerContext.container.scrollTop; - let mouseXRelativeToContainer = - mouseEvent.pageX - this.containerOffset.left + this.containerContext.container.scrollLeft; - - if (this.positionOptions.canvasRelative && !!em) { - const mousePos = em.Canvas.getMouseRelativeCanvas(mouseEvent, { noScroll: 1 }); - mouseXRelativeToContainer = mousePos.x; - mouseYRelativeToContainer = mousePos.y; + let mouseYRelative = mouseY - this.containerOffset.top + this.containerContext.container.scrollTop; + let mouseXRelative = mouseX - this.containerOffset.left + this.containerContext.container.scrollLeft; + + if (this.positionOptions.canvasRelative) { + const mousePos = this.em.Canvas.getMouseRelativeCanvas({ clientX: mouseX, clientY: mouseY }, { noScroll: 1 }); + mouseXRelative = mousePos.x; + mouseYRelative = mousePos.y; } - return { mouseXRelativeToContainer, mouseYRelativeToContainer }; + return { mouseXRelative, mouseYRelative }; } /** diff --git a/packages/core/src/utils/sorter/PlaceholderClass.ts b/packages/core/src/utils/sorter/PlaceholderClass.ts index b87f38ce6..8bb8c70b6 100644 --- a/packages/core/src/utils/sorter/PlaceholderClass.ts +++ b/packages/core/src/utils/sorter/PlaceholderClass.ts @@ -95,7 +95,7 @@ export class PlaceholderClass extends View { const borderWidth = borderLeftWidth + borderRightWidth; top = elTop + paddingTop + borderTopWidth; left = elLeft + paddingLeft + borderLeftWidth; - width = elWidth - paddingLeft * 2 - borderWidth + 'px'; + width = Math.max(elWidth - paddingLeft * 2 - borderWidth, 1) + 'px'; height = 'auto'; } else { if (!dir) { diff --git a/packages/core/src/utils/sorter/SortableTreeNode.ts b/packages/core/src/utils/sorter/SortableTreeNode.ts index ee18b619b..a80c6587a 100644 --- a/packages/core/src/utils/sorter/SortableTreeNode.ts +++ b/packages/core/src/utils/sorter/SortableTreeNode.ts @@ -1,5 +1,6 @@ import { View } from '../../common'; import Dimension from './Dimension'; +import { DroppableZoneConfig } from './types'; import { DragSource } from './types'; /** @@ -10,6 +11,11 @@ import { DragSource } from './types'; export abstract class SortableTreeNode { protected _model: T; protected _dragSource: DragSource; + protected _dropAreaConfig: DroppableZoneConfig = { + ratio: 1, + minUndroppableDimension: 0, // In px + maxUndroppableDimension: 0, // In px + }; /** The dimensions of the node. */ public nodeDimensions?: Dimension; /** The dimensions of the child elements within the target node. */ @@ -92,6 +98,23 @@ export abstract class SortableTreeNode { return this._dragSource; } + get dropArea(): Dimension | undefined { + // If no parent, there's no reason to reduce the drop zone + if (!this.getParent()) return this.nodeDimensions?.clone(); + return this.nodeDimensions?.getDropArea(this._dropAreaConfig); + } + + /** + * Checks if the given coordinates are within the bounds of this node. + * + * @param {number} x - The X coordinate to check. + * @param {number} y - The Y coordinate to check. + * @returns {boolean} - True if the coordinates are within bounds, otherwise false. + */ + public isWithinDropBounds(x: number, y: number): boolean { + return !!this.dropArea && this.dropArea.isWithinBounds(x, y); + } + equals(node?: SortableTreeNode): node is SortableTreeNode { return !!node?._model && this._model === node._model; } diff --git a/packages/core/src/utils/sorter/types.ts b/packages/core/src/utils/sorter/types.ts index 04d33baae..fc7e7d38d 100644 --- a/packages/core/src/utils/sorter/types.ts +++ b/packages/core/src/utils/sorter/types.ts @@ -26,6 +26,12 @@ export type DragSource = DraggableContent & { export type Placement = 'inside' | 'before' | 'after'; +export type DroppableZoneConfig = { + ratio: number; + minUndroppableDimension: number; // In px + maxUndroppableDimension: number; // In px +}; + export enum DragDirection { Vertical = 'Vertical', Horizontal = 'Horizontal',