mirror of https://github.com/artf/grapesjs.git
Browse Source
* Add canMove and getChildren to Sorter * Make the canvas use Components.canMove function * Fix StyleManager issue * Make Sorter dependent on an abstract datastructure * Remove unneeded properties * Add drag direction enum * extract method * Add default values for the sorter * reverse avoidSelectOnEnd * Group related options for the Sorter * Fix types, and fix canvas sorter * Add method to clear source element freeze * Add DropLocationDeterminer class * Refactor some methods in the sorter * Refactor endMove method * Refactor rollback method * Add DropLocationDeterminer, refactor methods in Sorter class * Break the sorter into multiple classes * Fix placeholder class jsdocs * Fix bug while dropping items near the border * Refactor DropLocationDeterminer * Add helping methods to the ComponentSorter * Remove unwanted fields in DropLocationDeterminer * Add componentSorter Class * Refactor DropLocationDeterminer Class * Rename file * Fix style manager class * Move files into a new folder * Move types to a new file * Add back the tree class paramater instead of abstract classes * Fix dropping on the same collection issue * refactor isInFlow * Change methods to be accessor methods * Make component sorter dependant on an abstract node * Fix bug with dropping into an element with no children * Fix component layers drag and drop * Remove the ensure placeholder logic from the StyleManagerSorter * Refactor event handler types * export interface SorterEventHandlers * change to ensurePlaceholderElement method * Make images droppable * Add support for dragging and dropping textable components * Remove container argument from startSort * Trigger events on starting and ending the dragging * Add triggering old events and callback functions * Fix drop issue with the style manager * Add cancelling the dragging * Hide placeholder if no target * Refactor droplocationdeteminer and improve performance * Fix some bugs * Fix some callbacks * trigger 'sorter:drag:validation' * Disable editable text after dropping * Remove unused functions * Fix placeholder direction * Refactor restnode state * Run formatter * Remove unused code and redundent comments * Fix stack test * Avoid triggering the drop event twice * Fix styles being removed when dragging components * change 'sorter:drag:start' event triggering * Fix legacyOnMove not being triggered * Some refactor * Fix selection after cancelling * Fix triggering update event without moving any component * fix the sort of dragged components * make style manager droppable even if the mouse is outside the continer * Fix selecting blocks after being dropped * Fix placeholder for empty containers * Fix blocks drag cancel issue * Fix style manager cancel * Fix autoscroll not stopping issue * Fix comment components drag * Fix issue with hidden elements * Fix selection after dropping * Format * Fix position on scroll * remove console.log * Change target calculation with scroll * Fix blocks not activating issue * Fix block drop in wrapper issue * Fix dropping multiple components in the same position * Preserve target canvas spot * Avoid updating non-text related component on drag/drop * skip sorter tests --------- Co-authored-by: Artur Arseniev <artur.catch@hotmail.it>pull/6182/head
committed by
GitHub
26 changed files with 2278 additions and 1462 deletions
File diff suppressed because it is too large
@ -0,0 +1,224 @@ |
|||
import { View } from '../../common'; |
|||
import Component from '../../dom_components/model/Component'; |
|||
import { SortableTreeNode } from './SortableTreeNode'; |
|||
|
|||
/** |
|||
* BaseComponentNode is an abstract class that provides basic operations |
|||
* for managing component nodes in a tree structure. It extends |
|||
* SortableTreeNode to handle sorting behavior for components. |
|||
* Subclasses must implement the `view` and `element` methods. |
|||
*/ |
|||
export abstract class BaseComponentNode extends SortableTreeNode<Component> { |
|||
constructor(model: Component, content?: any) { |
|||
super(model, content); |
|||
} |
|||
|
|||
/** |
|||
* Get the list of child components. |
|||
* @returns {BaseComponentNode[] | null} - The list of children wrapped in |
|||
* BaseComponentNode, or null if there are no children. |
|||
*/ |
|||
getChildren(): BaseComponentNode[] | null { |
|||
return this.getDisplayedChildren(); |
|||
} |
|||
|
|||
/** |
|||
* Get the list of displayed children, i.e., components that have a valid HTML element. |
|||
* @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); |
|||
}); |
|||
|
|||
return displayedChildren.map((comp: Component) => new (this.constructor as any)(comp)); |
|||
} |
|||
|
|||
/** |
|||
* Get the parent component of this node. |
|||
* @returns {BaseComponentNode | null} - The parent wrapped in BaseComponentNode, |
|||
* or null if no parent exists. |
|||
*/ |
|||
getParent(): BaseComponentNode | null { |
|||
const parent = this.model.parent(); |
|||
return parent ? new (this.constructor as any)(parent) : null; |
|||
} |
|||
|
|||
/** |
|||
* Add a child component to this node at the specified index. |
|||
* @param {BaseComponentNode} node - The child node to add. |
|||
* @param {number} displayIndex - The visual index at which to insert the child. |
|||
* @param {{ action: string }} options - Options for the operation, with the default action being 'add-component'. |
|||
* @returns {BaseComponentNode} - The newly added child node wrapped in BaseComponentNode. |
|||
*/ |
|||
addChildAt( |
|||
node: BaseComponentNode, |
|||
displayIndex: number, |
|||
options: { action: string } = { action: 'add-component' }, |
|||
): BaseComponentNode { |
|||
const insertingTextableIntoText = this.model?.isInstanceOf?.('text') && node?.model?.get?.('textable'); |
|||
|
|||
if (insertingTextableIntoText) { |
|||
// @ts-ignore: Handle inserting textable components
|
|||
return this.model?.getView?.()?.insertComponent?.(node?.model, { action: options.action }); |
|||
} |
|||
|
|||
const newModel = this.model.components().add(node.model, { |
|||
at: this.getRealIndex(displayIndex), |
|||
action: options.action, |
|||
}); |
|||
|
|||
return new (this.constructor as any)(newModel); |
|||
} |
|||
|
|||
/** |
|||
* Remove a child component at the specified index. |
|||
* @param {number} displayIndex - The visual index of the child to remove. |
|||
* @param {{ temporary: boolean }} options - Whether to temporarily remove the child. |
|||
*/ |
|||
removeChildAt(displayIndex: number, options: { temporary: boolean } = { temporary: false }): void { |
|||
const child = this.model.components().at(this.getRealIndex(displayIndex)); |
|||
if (child) { |
|||
this.model.components().remove(child, options as any); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get the visual index of a child node within the displayed children. |
|||
* @param {BaseComponentNode} node - The child node to locate. |
|||
* @returns {number} - The index of the child node, or -1 if not found. |
|||
*/ |
|||
indexOfChild(node: BaseComponentNode): number { |
|||
return this.getDisplayIndex(node); |
|||
} |
|||
|
|||
/** |
|||
* Get the index of the given node within the displayed children. |
|||
* @param {BaseComponentNode} node - The node to find. |
|||
* @returns {number} - The display index of the node, or -1 if not found. |
|||
*/ |
|||
private getDisplayIndex(node: BaseComponentNode): number { |
|||
const displayedChildren = this.getDisplayedChildren(); |
|||
return displayedChildren ? displayedChildren.findIndex((displayedNode) => displayedNode.model === node.model) : -1; |
|||
} |
|||
|
|||
/** |
|||
* Convert a display index to the actual index within the component's children array. |
|||
* @param {number} index - The display index to convert. |
|||
* @returns {number} - The corresponding real index, or -1 if not found. |
|||
*/ |
|||
getRealIndex(index: number): number { |
|||
if (index === -1) return -1; |
|||
|
|||
let displayedCount = 0; |
|||
const children = this.model.components(); |
|||
|
|||
for (let i = 0; i < children.length; i++) { |
|||
const child = children.at(i); |
|||
const element = child.getEl(); |
|||
const displayed = isDisplayed(element); |
|||
|
|||
if (displayed) displayedCount++; |
|||
if (displayedCount === index + 1) return i; |
|||
} |
|||
|
|||
return -1; |
|||
} |
|||
|
|||
/** |
|||
* Check if a source node can be moved to a specified index within this component. |
|||
* @param {BaseComponentNode} source - The source node to move. |
|||
* @param {number} index - The display index to move the source to. |
|||
* @returns {boolean} - True if the move is allowed, false otherwise. |
|||
*/ |
|||
canMove(source: BaseComponentNode, index: number): boolean { |
|||
return this.model.em.Components.canMove(this.model, source.model, this.getRealIndex(index)).result; |
|||
} |
|||
|
|||
/** |
|||
* Abstract method to get the view associated with this component. |
|||
* Subclasses must implement this method. |
|||
* @abstract |
|||
*/ |
|||
abstract get view(): any; |
|||
|
|||
/** |
|||
* Abstract method to get the DOM element associated with this component. |
|||
* Subclasses must implement this method. |
|||
* @abstract |
|||
*/ |
|||
abstract get element(): HTMLElement | undefined; |
|||
|
|||
/** |
|||
* Reset the state of the node by clearing its status and disabling editing. |
|||
*/ |
|||
restNodeState(): void { |
|||
this.clearState(); |
|||
const { model } = this; |
|||
this.setContentEditable(false); |
|||
model.em.getEditing() === model && this.disableEditing(); |
|||
} |
|||
|
|||
/** |
|||
* Set the contentEditable property of the node's DOM element. |
|||
* @param {boolean} value - True to make the content editable, false to disable editing. |
|||
*/ |
|||
setContentEditable(value: boolean): void { |
|||
if (this.element && this.isTextNode()) { |
|||
this.element.contentEditable = value ? 'true' : 'false'; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Disable editing capabilities for the component's view. |
|||
* This method depends on the presence of the `disableEditing` method in the view. |
|||
*/ |
|||
private disableEditing(): void { |
|||
// @ts-ignore
|
|||
this.view?.disableEditing?.(); |
|||
} |
|||
|
|||
/** |
|||
* Clear the current state of the node by resetting its status. |
|||
*/ |
|||
private clearState(): void { |
|||
this.model.set?.('status', ''); |
|||
} |
|||
|
|||
/** |
|||
* Set the state of the node to 'selected-parent'. |
|||
*/ |
|||
setSelectedParentState(): void { |
|||
this.model.set?.('status', 'selected-parent'); |
|||
} |
|||
|
|||
/** |
|||
* Determine if the component is a text node. |
|||
* @returns {boolean} - True if the component is a text node, false otherwise. |
|||
*/ |
|||
isTextNode(): boolean { |
|||
return this.model.isInstanceOf?.('text'); |
|||
} |
|||
|
|||
/** |
|||
* Determine if the component is textable. |
|||
* @returns {boolean} - True if the component is textable, false otherwise. |
|||
*/ |
|||
isTextable(): boolean { |
|||
return this.model.get?.('textable'); |
|||
} |
|||
} |
|||
|
|||
function isDisplayed(element: HTMLElement | undefined) { |
|||
if (!!!element) return false; |
|||
return ( |
|||
element instanceof HTMLElement && |
|||
window.getComputedStyle(element).display !== 'none' && |
|||
element.offsetWidth > 0 && |
|||
element.offsetHeight > 0 |
|||
); |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import { BaseComponentNode } from './BaseComponentNode'; |
|||
|
|||
export default class CanvasComponentNode extends BaseComponentNode { |
|||
/** |
|||
* Get the associated view of this component. |
|||
* @returns The view associated with the component, or undefined if none. |
|||
*/ |
|||
get view() { |
|||
return this.model.getView?.(); |
|||
} |
|||
|
|||
/** |
|||
* Get the associated element of this component. |
|||
* @returns The Element associated with the component, or undefined if none. |
|||
*/ |
|||
get element() { |
|||
return this.model.getEl?.(); |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
import CanvasComponentNode from './CanvasComponentNode'; |
|||
|
|||
export default class CanvasNewComponentNode extends CanvasComponentNode { |
|||
/** |
|||
* **Note:** For new components, this method will not directly add them to the target collection. |
|||
* Instead, the adding logic is handled in `Droppable.ts` to accommodate dragging various content types, |
|||
* such as images. |
|||
*/ |
|||
addChildAt(node: CanvasNewComponentNode, index: number): CanvasNewComponentNode { |
|||
const insertingTextableIntoText = this.isTextNode() && node.isTextable(); |
|||
let model; |
|||
if (insertingTextableIntoText) { |
|||
// @ts-ignore
|
|||
model = this.model?.getView?.()?.insertComponent?.(node._content, { action: 'add-component' }); |
|||
} else { |
|||
model = this.model |
|||
.components() |
|||
.add(node._content, { at: this.getRealIndex(index || -1), action: 'add-component' }); |
|||
} |
|||
|
|||
return new (this.constructor as any)(model); |
|||
} |
|||
|
|||
set content(content: any) { |
|||
this._content = content; |
|||
} |
|||
} |
|||
@ -0,0 +1,255 @@ |
|||
import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; |
|||
import Component from '../../dom_components/model/Component'; |
|||
import EditorModel from '../../editor/model/Editor'; |
|||
import { getPointerEvent } from '../dom'; |
|||
import { BaseComponentNode } from './BaseComponentNode'; |
|||
import Sorter from './Sorter'; |
|||
import { SorterContainerContext, PositionOptions, SorterDragBehaviorOptions, SorterEventHandlers } from './types'; |
|||
|
|||
const targetSpotType = CanvasSpotBuiltInTypes.Target; |
|||
|
|||
const spotTarget = { |
|||
id: 'sorter-target', |
|||
type: targetSpotType, |
|||
}; |
|||
|
|||
export default class ComponentSorter<NodeType extends BaseComponentNode> extends Sorter<Component, NodeType> { |
|||
targetIsText: boolean = false; |
|||
constructor({ |
|||
em, |
|||
treeClass, |
|||
containerContext, |
|||
dragBehavior, |
|||
positionOptions = {}, |
|||
eventHandlers = {}, |
|||
}: { |
|||
em: EditorModel; |
|||
treeClass: new (model: Component, content?: any) => NodeType; |
|||
containerContext: SorterContainerContext; |
|||
dragBehavior: SorterDragBehaviorOptions; |
|||
positionOptions?: PositionOptions; |
|||
eventHandlers?: SorterEventHandlers<NodeType>; |
|||
}) { |
|||
super({ |
|||
em, |
|||
treeClass, |
|||
containerContext, |
|||
positionOptions, |
|||
dragBehavior, |
|||
eventHandlers: { |
|||
...eventHandlers, |
|||
onStartSort: (sourceNodes: NodeType[], containerElement?: HTMLElement) => { |
|||
eventHandlers.onStartSort?.(sourceNodes, containerElement); |
|||
this.onStartSort(); |
|||
}, |
|||
onDrop: (targetNode: NodeType | undefined, sourceNodes: NodeType[], index: number | undefined) => { |
|||
eventHandlers.onDrop?.(targetNode, sourceNodes, index); |
|||
this.onDrop(targetNode, sourceNodes, index); |
|||
}, |
|||
onTargetChange: (oldTargetNode: NodeType | undefined, newTargetNode: NodeType | undefined) => { |
|||
eventHandlers.onTargetChange?.(oldTargetNode, newTargetNode); |
|||
this.onTargetChange(oldTargetNode, newTargetNode); |
|||
}, |
|||
onMouseMove: (mouseEvent) => { |
|||
eventHandlers.onMouseMove?.(mouseEvent); |
|||
this.onMouseMove(mouseEvent); |
|||
}, |
|||
}, |
|||
}); |
|||
} |
|||
|
|||
private onStartSort() { |
|||
this.em.clearSelection(); |
|||
this.setAutoCanvasScroll(true); |
|||
} |
|||
|
|||
private onMouseMove = (mouseEvent: MouseEvent) => { |
|||
const insertingTextableIntoText = this.targetIsText && this.sourceNodes?.some((node) => node.isTextable()); |
|||
if (insertingTextableIntoText) { |
|||
this.updateTextViewCursorPosition(mouseEvent); |
|||
} |
|||
}; |
|||
|
|||
/** |
|||
* Handles the drop action by moving the source nodes to the target node. |
|||
* Calls appropriate handlers based on whether the move was successful or not. |
|||
* |
|||
* @param targetNode - The node where the source nodes will be dropped. |
|||
* @param sourceNodes - The nodes being dropped. |
|||
* @param index - The index at which to drop the source nodes. |
|||
*/ |
|||
private onDrop = (targetNode: NodeType | undefined, sourceNodes: NodeType[], index: number | undefined): void => { |
|||
const at = typeof index === 'number' ? index : -1; |
|||
if (targetNode && sourceNodes.length > 0) { |
|||
const addedNodes = this.handleNodeAddition(targetNode, sourceNodes, at); |
|||
if (addedNodes.length === 0) this.triggerNullOnEndMove(false); |
|||
} else { |
|||
this.triggerNullOnEndMove(true); |
|||
} |
|||
|
|||
targetNode?.restNodeState(); |
|||
this.placeholder.hide(); |
|||
}; |
|||
|
|||
/** |
|||
* Handles the addition of multiple source nodes to the target node. |
|||
* If the move is valid, adds the nodes at the specified index and increments the index. |
|||
* |
|||
* @param targetNode - The target node where source nodes will be added. |
|||
* @param sourceNodes - The nodes being added. |
|||
* @param index - The initial index at which to add the source nodes. |
|||
* @returns The list of successfully added nodes. |
|||
*/ |
|||
private handleNodeAddition(targetNode: NodeType, sourceNodes: NodeType[], index: number): NodeType[] { |
|||
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); |
|||
addedNodes.push(addedNode); |
|||
} |
|||
index++; // Increment the index
|
|||
return addedNodes; |
|||
}, [] as NodeType[]); |
|||
} |
|||
|
|||
/** |
|||
* Determines if a source node position has changed. |
|||
* |
|||
* @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 Whether the node can be moved. |
|||
*/ |
|||
private isPositionChanged(targetNode: NodeType, sourceNode: NodeType, index: number): boolean { |
|||
const parent = sourceNode.getParent(); |
|||
const initialSourceIndex = parent ? parent.indexOfChild(sourceNode) : -1; |
|||
if (parent?.model.cid === targetNode.model.cid && initialSourceIndex < index) { |
|||
index--; // Adjust index if moving within the same collection and after the initial position
|
|||
} |
|||
|
|||
const isSameCollection = parent?.model.cid === targetNode.model.cid; |
|||
const isSameIndex = initialSourceIndex === index; |
|||
const insertingTextableIntoText = this.targetIsText && sourceNode.isTextable(); |
|||
|
|||
return !(isSameCollection && isSameIndex && !insertingTextableIntoText); |
|||
} |
|||
|
|||
/** |
|||
* Moves a source node to the target node at the specified index, handling edge cases. |
|||
* |
|||
* @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. |
|||
*/ |
|||
private moveNode(targetNode: NodeType, sourceNode: NodeType, index: number): NodeType { |
|||
const parent = sourceNode.getParent(); |
|||
if (parent) { |
|||
const initialSourceIndex = parent.indexOfChild(sourceNode); |
|||
parent.removeChildAt(initialSourceIndex, { temporary: true }); |
|||
|
|||
if (parent.model.cid === targetNode.model.cid && initialSourceIndex < index) { |
|||
index--; // Adjust index if moving within the same collection and after the initial position
|
|||
} |
|||
} |
|||
const addedNode = targetNode.addChildAt(sourceNode, index, { action: 'move-component' }) as NodeType; |
|||
this.triggerEndMoveEvent(addedNode); |
|||
|
|||
return addedNode; |
|||
} |
|||
|
|||
/** |
|||
* Triggers the end move event for a node that was added to the target. |
|||
* |
|||
* @param addedNode - The node that was moved and added to the target. |
|||
*/ |
|||
private triggerEndMoveEvent(addedNode: NodeType): void { |
|||
this.eventHandlers.legacyOnEndMove?.(addedNode.model, this, { |
|||
target: addedNode.model, |
|||
// @ts-ignore
|
|||
parent: addedNode.model && addedNode.model.parent?.(), |
|||
// @ts-ignore
|
|||
index: addedNode.model && addedNode.model.index?.(), |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Finalize the move by removing any helpers and selecting the target model. |
|||
* |
|||
* @private |
|||
*/ |
|||
protected finalizeMove(): void { |
|||
this.em?.Canvas.removeSpots(spotTarget); |
|||
this.sourceNodes?.forEach((node) => node.restNodeState()); |
|||
this.setAutoCanvasScroll(false); |
|||
super.finalizeMove(); |
|||
} |
|||
|
|||
private onTargetChange = (oldTargetNode: NodeType | undefined, newTargetNode: NodeType | undefined) => { |
|||
oldTargetNode?.restNodeState(); |
|||
if (!newTargetNode) { |
|||
this.placeholder.hide(); |
|||
return; |
|||
} |
|||
newTargetNode?.setSelectedParentState(); |
|||
this.targetIsText = newTargetNode.isTextNode(); |
|||
const insertingTextableIntoText = this.targetIsText && this.sourceNodes?.some((node) => node.isTextable()); |
|||
if (insertingTextableIntoText) { |
|||
newTargetNode.setContentEditable(true); |
|||
this.placeholder.hide(); |
|||
} else { |
|||
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 }); |
|||
}; |
|||
|
|||
private updateTextViewCursorPosition(e: any) { |
|||
const { em } = this; |
|||
if (!em) return; |
|||
const Canvas = em.Canvas; |
|||
const targetDoc = Canvas.getDocument(); |
|||
let range = null; |
|||
|
|||
const poiner = getPointerEvent(e); |
|||
|
|||
// @ts-ignore
|
|||
if (targetDoc.caretPositionFromPoint) { |
|||
// New standard method
|
|||
// @ts-ignore
|
|||
const caretPosition = targetDoc.caretPositionFromPoint(poiner.clientX, poiner.clientY); |
|||
if (caretPosition) { |
|||
range = targetDoc.createRange(); |
|||
range.setStart(caretPosition.offsetNode, caretPosition.offset); |
|||
} |
|||
} else if (targetDoc.caretRangeFromPoint) { |
|||
// Fallback for older browsers
|
|||
range = targetDoc.caretRangeFromPoint(poiner.clientX, poiner.clientY); |
|||
} else if (e.rangeParent) { |
|||
// Firefox fallback
|
|||
range = targetDoc.createRange(); |
|||
range.setStart(e.rangeParent, e.rangeOffset); |
|||
} |
|||
|
|||
const sel = Canvas.getWindow().getSelection(); |
|||
Canvas.getFrameEl().focus(); |
|||
sel?.removeAllRanges(); |
|||
range && sel?.addRange(range); |
|||
} |
|||
|
|||
/** |
|||
* Change Autoscroll while sorting |
|||
* @param {Boolean} active |
|||
*/ |
|||
private setAutoCanvasScroll(active?: boolean) { |
|||
const { em } = this; |
|||
const cv = em?.Canvas; |
|||
|
|||
// Avoid updating body className as it causes a huge repaint
|
|||
// Noticeable with "fast" drag of blocks
|
|||
cv && (active ? cv.startAutoscroll() : cv.stopAutoscroll()); |
|||
} |
|||
} |
|||
@ -0,0 +1,460 @@ |
|||
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 } from './types'; |
|||
import { bindAll, each } from 'underscore'; |
|||
import { matches, findPosition, offset, isInFlow } from './SorterUtils'; |
|||
|
|||
type ContainerContext = { |
|||
container: HTMLElement; |
|||
itemSel: string; |
|||
customTarget?: CustomTarget; |
|||
document: Document; |
|||
}; |
|||
|
|||
interface DropLocationDeterminerOptions<T, NodeType extends SortableTreeNode<T>> { |
|||
em: EditorModel; |
|||
treeClass: new (model: T, content?: any) => NodeType; |
|||
containerContext: ContainerContext; |
|||
positionOptions: PositionOptions; |
|||
dragDirection: DragDirection; |
|||
eventHandlers: SorterEventHandlers<NodeType>; |
|||
} |
|||
|
|||
/** |
|||
* 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<NodeType> = { |
|||
/** The target node under the mouse pointer during the last move. */ |
|||
lastTargetNode?: NodeType; |
|||
/** The index where the placeholder or dragged element should be inserted. */ |
|||
lastIndex?: 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[]; |
|||
/** The mouse event, used if we want to move placeholder with scrolling. */ |
|||
lastMouseEvent?: MouseEvent; |
|||
}; |
|||
|
|||
export class DropLocationDeterminer<T, NodeType extends SortableTreeNode<T>> extends View { |
|||
em: EditorModel; |
|||
treeClass: new (model: any) => NodeType; |
|||
|
|||
positionOptions: PositionOptions; |
|||
containerContext: ContainerContext; |
|||
dragDirection: DragDirection; |
|||
eventHandlers: SorterEventHandlers<NodeType>; |
|||
|
|||
sourceNodes: NodeType[] = []; |
|||
lastMoveData!: LastMoveData<NodeType>; |
|||
containerOffset = { |
|||
top: 0, |
|||
left: 0, |
|||
}; |
|||
|
|||
constructor(options: DropLocationDeterminerOptions<T, NodeType>) { |
|||
super(); |
|||
this.treeClass = options.treeClass; |
|||
this.em = options.em; |
|||
this.containerContext = options.containerContext; |
|||
this.positionOptions = options.positionOptions; |
|||
this.dragDirection = options.dragDirection; |
|||
this.eventHandlers = options.eventHandlers; |
|||
bindAll(this, 'endDrag', 'cancelDrag', 'recalculateTargetOnScroll', 'startSort', 'onDragStart', 'onMove'); |
|||
|
|||
this.restLastMoveData(); |
|||
} |
|||
|
|||
/** |
|||
* Picking components to move |
|||
* @param {HTMLElement[]} sourceElements |
|||
* */ |
|||
startSort(sourceNodes: NodeType[]) { |
|||
this.sourceNodes = sourceNodes; |
|||
this.bindDragEventHandlers(); |
|||
} |
|||
|
|||
private bindDragEventHandlers() { |
|||
on(this.containerContext.container, 'dragstart', this.onDragStart); |
|||
on(this.containerContext.container, 'mousemove dragover', this.onMove); |
|||
on(this.containerContext.document, 'mouseup dragend touchend', this.endDrag); |
|||
} |
|||
|
|||
/** |
|||
* Triggers the `onMove` event. |
|||
* |
|||
* This method is should be called when the user scrolls within the container, using the last recorded mouse event |
|||
* 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; |
|||
if (!lastMouseEvent) { |
|||
return; |
|||
} |
|||
|
|||
this.onMove(lastMouseEvent); |
|||
this.lastMoveData.lastMouseEvent = lastMouseEvent; |
|||
} |
|||
|
|||
private onMove(mouseEvent: MouseEvent): void { |
|||
this.eventHandlers.onMouseMove?.(mouseEvent); |
|||
const { mouseXRelativeToContainer: mouseX, mouseYRelativeToContainer: mouseY } = |
|||
this.getMousePositionRelativeToContainer(mouseEvent); |
|||
const targetNode = this.getTargetNode(mouseEvent); |
|||
if (!targetNode) { |
|||
this.triggerLegacyOnMoveCallback(mouseEvent, 0); |
|||
this.triggerMoveEvent(mouseX, mouseY); |
|||
|
|||
return; |
|||
} |
|||
|
|||
// Handle movement over the valid target node
|
|||
const index = this.handleMovementOnTarget(targetNode, mouseX, mouseY); |
|||
|
|||
this.triggerMoveEvent(mouseX, mouseY); |
|||
this.triggerLegacyOnMoveCallback(mouseEvent, index); |
|||
this.lastMoveData.lastMouseEvent = mouseEvent; |
|||
} |
|||
|
|||
private restLastMoveData() { |
|||
this.lastMoveData = { |
|||
lastTargetNode: undefined, |
|||
lastIndex: undefined, |
|||
lastPlacement: undefined, |
|||
lastTargetDimensions: undefined, |
|||
lastChildrenDimensions: undefined, |
|||
lastMouseEvent: undefined, |
|||
}; |
|||
} |
|||
|
|||
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, |
|||
index: index, |
|||
}); |
|||
} |
|||
|
|||
private triggerMoveEvent(mouseX: number, mouseY: number) { |
|||
const { |
|||
lastTargetNode: targetNode, |
|||
lastPlacement: placement, |
|||
lastIndex: index, |
|||
lastChildrenDimensions: childrenDimensions, |
|||
} = 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, |
|||
sourceModel: this.sourceNodes[0].model, |
|||
dims: childrenDimensions || [], |
|||
pos: { |
|||
index: legacyIndex, |
|||
indexEl: legacyIndex, |
|||
placement, |
|||
}, |
|||
x: mouseX, |
|||
y: mouseY, |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 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 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(); |
|||
const nodeHasChildren = children && children.length > 0; |
|||
|
|||
const hoveredNodeDimensions = this.getDim(hoveredNode.element!); |
|||
const childrenDimensions = |
|||
targetChanged || !!!lastChildrenDimensions ? this.getChildrenDim(hoveredNode) : lastChildrenDimensions; |
|||
if (nodeHasChildren) { |
|||
({ index, placement } = findPosition(childrenDimensions, mouseX, mouseY)); |
|||
placeholderDimensions = childrenDimensions[index]; |
|||
} else { |
|||
placeholderDimensions = hoveredNodeDimensions; |
|||
index = 0; |
|||
placement = 'inside'; |
|||
} |
|||
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; |
|||
} |
|||
|
|||
private getTargetNode(mouseEvent: MouseEvent) { |
|||
const customTarget = this.containerContext.customTarget; |
|||
this.cacheContainerPosition(this.containerContext.container); |
|||
|
|||
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; |
|||
} |
|||
|
|||
private onDragStart(mouseEvent: MouseEvent): void { |
|||
this.eventHandlers.onDragStart && this.eventHandlers.onDragStart(mouseEvent); |
|||
} |
|||
|
|||
endDrag(): void { |
|||
this.dropDragged(); |
|||
} |
|||
|
|||
cancelDrag() { |
|||
const { lastTargetNode } = this.lastMoveData; |
|||
this.eventHandlers.onTargetChange?.(lastTargetNode, undefined); |
|||
this.finalizeMove(); |
|||
} |
|||
|
|||
private finalizeMove() { |
|||
this.cleanupEventListeners(); |
|||
this.triggerOnDragEndEvent(); |
|||
this.eventHandlers.onEnd?.(); |
|||
this.eventHandlers.legacyOnEnd?.(); |
|||
this.restLastMoveData(); |
|||
} |
|||
|
|||
private dropDragged() { |
|||
const { lastTargetNode, lastIndex } = this.lastMoveData; |
|||
this.eventHandlers.onDrop?.(lastTargetNode, this.sourceNodes, lastIndex); |
|||
this.finalizeMove(); |
|||
} |
|||
|
|||
private triggerOnDragEndEvent() { |
|||
const { lastTargetNode: targetNode } = this.lastMoveData; |
|||
|
|||
// For backward compatibility, leave it to a single node
|
|||
const firstSourceNode = this.sourceNodes[0]; |
|||
this.em.trigger('sorter:drag:end', { |
|||
targetCollection: targetNode ? targetNode.getChildren() : null, |
|||
modelToDrop: firstSourceNode?.model, |
|||
warns: [''], |
|||
validResult: { |
|||
result: true, |
|||
src: this.sourceNodes.map((node) => node.element), |
|||
srcModel: firstSourceNode?.model, |
|||
trg: targetNode?.element, |
|||
trgModel: targetNode?.model, |
|||
draggable: true, |
|||
droppable: true, |
|||
}, |
|||
dst: targetNode?.element, |
|||
srcEl: firstSourceNode?.element, |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Retrieves the first element that has a data model associated with it. |
|||
* Traverses up the DOM tree from the given element until it reaches the container |
|||
* or an element with a data model. |
|||
* |
|||
* @param mouseTargetEl - The element to start searching from. |
|||
* @returns The first element with a data model, or null if not found. |
|||
*/ |
|||
private getFirstElementWithAModel(mouseTargetEl: HTMLElement | null): HTMLElement | null { |
|||
const isModelPresent = (el: HTMLElement) => $(el).data('model') !== undefined; |
|||
|
|||
while (mouseTargetEl && this.containerContext.container.contains(mouseTargetEl)) { |
|||
if (isModelPresent(mouseTargetEl)) { |
|||
return mouseTargetEl; |
|||
} |
|||
|
|||
mouseTargetEl = mouseTargetEl.parentElement; |
|||
} |
|||
|
|||
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; |
|||
} |
|||
|
|||
return finalNode; |
|||
} |
|||
|
|||
/** |
|||
* Clean up event listeners that were attached during the move. |
|||
* |
|||
* @param {HTMLElement} container - The container element. |
|||
* @param {Document[]} docs - List of documents. |
|||
* @private |
|||
*/ |
|||
private cleanupEventListeners(): void { |
|||
const container = this.containerContext.container; |
|||
off(container, 'dragstart', this.onDragStart); |
|||
off(container, 'mousemove dragover', this.onMove); |
|||
off(this.containerContext.document, 'mouseup dragend touchend', this.endDrag); |
|||
} |
|||
|
|||
/** |
|||
* Get children dimensions |
|||
* @param {NodeType} el Element root |
|||
* @return {Array} |
|||
* */ |
|||
private getChildrenDim(targetNode: NodeType) { |
|||
const dims: Dimension[] = []; |
|||
const targetElement = targetNode.element; |
|||
if (!!!targetElement) { |
|||
return []; |
|||
} |
|||
|
|||
const children = targetNode.getChildren(); |
|||
if (!children || children.length === 0) { |
|||
return []; |
|||
} |
|||
|
|||
each(children, (sortableTreeNode, i) => { |
|||
const el = sortableTreeNode.element; |
|||
if (!el) return; |
|||
|
|||
if (!isTextNode(el) && !matches(el, this.containerContext.itemSel)) { |
|||
return; |
|||
} |
|||
|
|||
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; |
|||
dims.push(dim); |
|||
}); |
|||
|
|||
return dims; |
|||
} |
|||
|
|||
/** |
|||
* 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; |
|||
} { |
|||
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; |
|||
} |
|||
|
|||
return { mouseXRelativeToContainer, mouseYRelativeToContainer }; |
|||
} |
|||
|
|||
/** |
|||
* Caches the container position and updates relevant variables for position calculation. |
|||
* |
|||
* @private |
|||
*/ |
|||
private cacheContainerPosition(container: HTMLElement): void { |
|||
const containerOffset = offset(container); |
|||
const containerOffsetTop = this.positionOptions.windowMargin ? Math.abs(containerOffset.top) : containerOffset.top; |
|||
const containerOffsetLeft = this.positionOptions.windowMargin |
|||
? Math.abs(containerOffset.left) |
|||
: containerOffset.left; |
|||
|
|||
this.containerOffset = { |
|||
top: containerOffsetTop, |
|||
left: containerOffsetLeft, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Returns dimensions and positions about the element |
|||
* @param {HTMLElement} el |
|||
* @return {Dimension} |
|||
*/ |
|||
private getDim(el: HTMLElement): Dimension { |
|||
const em = this.em; |
|||
const relative = this.positionOptions.relative; |
|||
const windowMargin = this.positionOptions.windowMargin; |
|||
const canvas = em?.Canvas; |
|||
const offsets = canvas ? canvas.getElementOffsets(el) : {}; |
|||
let top, left, height, width; |
|||
|
|||
if (this.positionOptions.canvasRelative && this.em) { |
|||
const pos = canvas!.getElementPos(el, { noScroll: 1 })!; |
|||
top = pos.top; // - offsets.marginTop;
|
|||
left = pos.left; // - offsets.marginLeft;
|
|||
height = pos.height; // + offsets.marginTop + offsets.marginBottom;
|
|||
width = pos.width; // + offsets.marginLeft + offsets.marginRight;
|
|||
} else { |
|||
var o = offset(el); |
|||
top = relative ? el.offsetTop : o.top - (windowMargin ? -1 : 1) * this.containerOffset.top; |
|||
left = relative ? el.offsetLeft : o.left - (windowMargin ? -1 : 1) * this.containerOffset.left; |
|||
height = el.offsetHeight; |
|||
width = el.offsetWidth; |
|||
} |
|||
|
|||
return { top, left, height, width, offsets }; |
|||
} |
|||
} |
|||
@ -0,0 +1,113 @@ |
|||
import Layer from '../../style_manager/model/Layer'; |
|||
import Layers from '../../style_manager/model/Layers'; |
|||
import { SortableTreeNode } from './SortableTreeNode'; |
|||
|
|||
/** |
|||
* Represents a node in the tree of Layers or Layer components. |
|||
* Extends the SortableTreeNode class for handling tree sorting logic. |
|||
*/ |
|||
export class LayerNode extends SortableTreeNode<Layer | Layers> { |
|||
/** |
|||
* Constructor for creating a new LayerNode instance. |
|||
* @param model - The Layer or Layers model associated with this node. |
|||
*/ |
|||
constructor(model: Layer | Layers) { |
|||
super(model); |
|||
} |
|||
|
|||
/** |
|||
* Get the list of children of this Layer or Layers component. |
|||
* @returns An array of LayerNode instances representing the children. |
|||
*/ |
|||
getChildren(): LayerNode[] | null { |
|||
if (this.model instanceof Layers) { |
|||
return this.model.models.map((model) => new LayerNode(model)); |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Get the parent LayerNode of this component, or null if it has no parent. |
|||
* @returns The parent LayerNode or null. |
|||
*/ |
|||
getParent(): LayerNode | null { |
|||
const collection = this.model instanceof Layer ? this.model.collection : null; |
|||
return collection ? new LayerNode(collection as Layers) : null; |
|||
} |
|||
|
|||
/** |
|||
* Add a child LayerNode at a particular index in the Layers model. |
|||
* @param node - The LayerNode to add as a child. |
|||
* @param index - The position to insert the child. |
|||
* @returns The newly added LayerNode. |
|||
* @throws Error if trying to add to a Layer (not a Layers). |
|||
*/ |
|||
addChildAt(node: LayerNode, index: number) { |
|||
if (this.model instanceof Layer) { |
|||
throw Error('Cannot add a layer model to another layer model'); |
|||
} |
|||
|
|||
const newModel = this.model.add(node.model, { at: index }); |
|||
return new LayerNode(newModel); |
|||
} |
|||
|
|||
/** |
|||
* Remove a child LayerNode at a specified index in the Layers model. |
|||
* @param index - The index of the child to remove. |
|||
* @returns The removed LayerNode. |
|||
* @throws Error if trying to remove from a Layer (not a Layers). |
|||
*/ |
|||
removeChildAt(index: number) { |
|||
if (this.model instanceof Layer) { |
|||
throw Error('Cannot remove a layer model from another layer model'); |
|||
} |
|||
|
|||
const child = this.model.at(index); |
|||
if (child) { |
|||
this.model.remove(child); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get the index of a child LayerNode in the current Layers model. |
|||
* @param node - The child LayerNode to find. |
|||
* @returns The index of the child, or -1 if not found. |
|||
*/ |
|||
indexOfChild(node: LayerNode): number { |
|||
if (!(node.model instanceof Layer) || !(this.model instanceof Layers)) { |
|||
return -1; |
|||
} |
|||
return this.model.indexOf(node.model); |
|||
} |
|||
|
|||
/** |
|||
* Determine if a source LayerNode can be moved to a specific index. |
|||
* @param source - The source LayerNode to be moved. |
|||
* @param index - The index to move the source to. |
|||
* @returns True if the source can be moved, false otherwise. |
|||
*/ |
|||
canMove(source: LayerNode, index: number): boolean { |
|||
return this.model instanceof Layers && !!source.model; |
|||
} |
|||
|
|||
/** |
|||
* Get the view associated with this LayerNode's model. |
|||
* @returns The associated view or undefined if none. |
|||
*/ |
|||
get view(): any { |
|||
return this.model.view; |
|||
} |
|||
|
|||
/** |
|||
* Get the DOM element associated with this LayerNode's view. |
|||
* @returns The associated HTMLElement or undefined. |
|||
*/ |
|||
get element(): HTMLElement | undefined { |
|||
return this.view?.el; |
|||
} |
|||
|
|||
get model(): Layer | Layers { |
|||
return this._model; |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import { BaseComponentNode } from './BaseComponentNode'; |
|||
|
|||
export default class LayersComponentNode extends BaseComponentNode { |
|||
/** |
|||
* Get the associated view of this component. |
|||
* @returns The view associated with the component, or undefined if none. |
|||
*/ |
|||
get view(): any { |
|||
return this.model.viewLayer; |
|||
} |
|||
|
|||
/** |
|||
* Get the associated element of this component. |
|||
* @returns The Element associated with the component, or undefined if none. |
|||
*/ |
|||
get element(): HTMLElement | undefined { |
|||
return this.model.viewLayer?.el; |
|||
} |
|||
} |
|||
@ -0,0 +1,136 @@ |
|||
import { View } from '../../common'; |
|||
import { Dimension, Placement } from './types'; |
|||
|
|||
export class PlaceholderClass extends View { |
|||
pfx: string; |
|||
allowNesting: boolean; |
|||
container: HTMLElement; |
|||
el!: HTMLElement; |
|||
offset: { |
|||
top: number; |
|||
left: number; |
|||
}; |
|||
constructor(options: { |
|||
container: HTMLElement; |
|||
pfx?: string; |
|||
allowNesting?: boolean; |
|||
el: HTMLElement; |
|||
offset: { |
|||
top: number; |
|||
left: number; |
|||
}; |
|||
}) { |
|||
super(); |
|||
this.pfx = options.pfx || ''; |
|||
this.allowNesting = options.allowNesting || false; |
|||
this.container = options.container; |
|||
this.setElement(options.el); |
|||
this.offset = { |
|||
top: options.offset.top || 0, |
|||
left: options.offset.left || 0, |
|||
}; |
|||
} |
|||
|
|||
show() { |
|||
this.el.style.display = 'block'; |
|||
} |
|||
|
|||
hide() { |
|||
this.el.style.display = 'none'; |
|||
} |
|||
|
|||
/** |
|||
* Updates the position of the placeholder. |
|||
* @param {Dimension} elementDimension element dimensions. |
|||
* @param {Position} placement either before or after the target. |
|||
*/ |
|||
move(elementDimension: Dimension, placement: Placement) { |
|||
const marginOffset = 0; |
|||
const unit = 'px'; |
|||
let top = 0; |
|||
let left = 0; |
|||
let width = ''; |
|||
let height = ''; |
|||
this.setOrientationForDimension(elementDimension); |
|||
const { top: elTop, left: elLeft, height: elHeight, width: elWidth, dir, offsets } = elementDimension; |
|||
|
|||
if (placement === 'inside') { |
|||
this.setOrientation('horizontal'); |
|||
if (!this.allowNesting) { |
|||
this.hide(); |
|||
return; |
|||
} |
|||
const defaultMargin = 5; |
|||
const paddingTop = offsets?.paddingTop || defaultMargin; |
|||
const paddingLeft = offsets?.paddingLeft || defaultMargin; |
|||
const borderTopWidth = offsets?.borderTopWidth || 0; |
|||
const borderLeftWidth = offsets?.borderLeftWidth || 0; |
|||
const borderRightWidth = offsets?.borderRightWidth || 0; |
|||
|
|||
const borderWidth = borderLeftWidth + borderRightWidth; |
|||
top = elTop + paddingTop + borderTopWidth; |
|||
left = elLeft + paddingLeft + borderLeftWidth; |
|||
width = elWidth - paddingLeft * 2 - borderWidth + 'px'; |
|||
height = 'auto'; |
|||
} else { |
|||
if (!dir) { |
|||
// If element is not in flow (e.g., a floating element)
|
|||
width = 'auto'; |
|||
height = elHeight - marginOffset * 2 + unit; |
|||
top = elTop + marginOffset; |
|||
left = placement === 'before' ? elLeft - marginOffset : elLeft + elWidth - marginOffset; |
|||
|
|||
this.setOrientation('vertical'); |
|||
} else { |
|||
width = elWidth + unit; |
|||
height = 'auto'; |
|||
top = placement === 'before' ? elTop - marginOffset : elTop + elHeight - marginOffset; |
|||
left = elLeft; |
|||
} |
|||
} |
|||
|
|||
this.updateStyles(top, left, width, height); |
|||
this.adjustOffset(); |
|||
} |
|||
|
|||
/** |
|||
* Sets the orientation of the placeholder based on the element dimensions. |
|||
* @param {Dimension} elementDimension Dimensions of the element at the index. |
|||
*/ |
|||
private setOrientationForDimension(elementDimension?: Dimension) { |
|||
this.el.classList.remove('vertical'); |
|||
this.el.classList.add('horizontal'); |
|||
|
|||
if (elementDimension && !elementDimension.dir) { |
|||
this.setOrientation('vertical'); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Sets the placeholder's class to vertical. |
|||
*/ |
|||
private setOrientation(orientation: 'horizontal' | 'vertical') { |
|||
this.el.classList.remove('horizontal'); |
|||
this.el.classList.remove('vertical'); |
|||
this.el.classList.add(orientation); |
|||
} |
|||
|
|||
/** |
|||
* Updates the CSS styles of the placeholder element. |
|||
* @param {number} top Top position of the placeholder. |
|||
* @param {number} left Left position of the placeholder. |
|||
* @param {string} width Width of the placeholder. |
|||
* @param {string} height Height of the placeholder. |
|||
*/ |
|||
private updateStyles(top: number, left: number, width: string, height: string) { |
|||
this.el.style.top = top + 'px'; |
|||
this.el.style.left = left + 'px'; |
|||
if (width) this.el.style.width = width; |
|||
if (height) this.el.style.height = height; |
|||
} |
|||
|
|||
private adjustOffset() { |
|||
this.$el.css('top', '+=' + this.offset.top + 'px'); |
|||
this.$el.css('left', '+=' + this.offset.left + 'px'); |
|||
} |
|||
} |
|||
@ -0,0 +1,92 @@ |
|||
import { $, View } from '../../common'; |
|||
|
|||
/** |
|||
* Base class for managing tree-like structures with sortable nodes. |
|||
* |
|||
* @template T - The type of the model that the tree nodes represent. |
|||
*/ |
|||
export abstract class SortableTreeNode<T> { |
|||
protected _model: T; |
|||
protected _content: any; |
|||
constructor(model: T, content?: any) { |
|||
this._model = model; |
|||
this._content = content; |
|||
} |
|||
/** |
|||
* Get the list of children of this node. |
|||
* |
|||
* @returns {SortableTreeNode<T>[] | null} - List of children or null if no children exist. |
|||
*/ |
|||
abstract getChildren(): SortableTreeNode<T>[] | null; |
|||
|
|||
/** |
|||
* Get the parent node of this node, or null if it has no parent. |
|||
* |
|||
* @returns {SortableTreeNode<T> | null} - Parent node or null if it has no parent. |
|||
*/ |
|||
abstract getParent(): SortableTreeNode<T> | null; |
|||
|
|||
/** |
|||
* Add a child node at a particular index. |
|||
* |
|||
* @param {SortableTreeNode<T>} node - The node to add. |
|||
* @param {number} index - The position to insert the child node at. |
|||
* @returns {SortableTreeNode<T>} - The added node. |
|||
*/ |
|||
abstract addChildAt(node: SortableTreeNode<T>, index: number): SortableTreeNode<T>; |
|||
|
|||
/** |
|||
* Remove a child node at a particular index. |
|||
* |
|||
* @param {number} index - The index to remove the child node from. |
|||
*/ |
|||
abstract removeChildAt(index: number): void; |
|||
|
|||
/** |
|||
* Get the index of a child node in the current node's list of children. |
|||
* |
|||
* @param {SortableTreeNode<T>} node - The node whose index is to be found. |
|||
* @returns {number} - The index of the node, or -1 if the node is not a child. |
|||
*/ |
|||
abstract indexOfChild(node: SortableTreeNode<T>): number; |
|||
|
|||
/** |
|||
* Determine if a node can be moved to a specific index in another node's children list. |
|||
* |
|||
* @param {SortableTreeNode<T>} source - The node to be moved. |
|||
* @param {number} index - The index at which the node will be inserted. |
|||
* @returns {boolean} - True if the move is allowed, false otherwise. |
|||
*/ |
|||
abstract canMove(source: SortableTreeNode<T>, index: number): boolean; |
|||
|
|||
/** |
|||
* Get the view associated with this node, if any. |
|||
* |
|||
* @returns {View | undefined} - The view associated with this node, or undefined if none. |
|||
*/ |
|||
abstract get view(): View | undefined; |
|||
|
|||
/** |
|||
* Get the HTML element associated with this node. |
|||
* |
|||
* @returns {HTMLElement} - The associated HTML element. |
|||
*/ |
|||
abstract get element(): HTMLElement | undefined; |
|||
|
|||
/** |
|||
* Get the model associated with this node. |
|||
* |
|||
* @returns {T} - The associated model. |
|||
*/ |
|||
get model(): T { |
|||
return this._model; |
|||
} |
|||
|
|||
get content(): T { |
|||
return this._content; |
|||
} |
|||
|
|||
equals(node?: SortableTreeNode<T>): boolean { |
|||
return !!node?._model && this._model === node._model; |
|||
} |
|||
} |
|||
@ -0,0 +1,239 @@ |
|||
import { bindAll } from 'underscore'; |
|||
import { $ } from '../../common'; |
|||
import EditorModel from '../../editor/model/Editor'; |
|||
import { off, on } from '../dom'; |
|||
import { SortableTreeNode } from './SortableTreeNode'; |
|||
import { DropLocationDeterminer } from './DropLocationDeterminer'; |
|||
import { PlaceholderClass } from './PlaceholderClass'; |
|||
import { getMergedOptions, getDocument, matches, closest, sortDom } from './SorterUtils'; |
|||
import { |
|||
SorterContainerContext, |
|||
PositionOptions, |
|||
SorterDragBehaviorOptions, |
|||
SorterEventHandlers, |
|||
Dimension, |
|||
Placement, |
|||
} from './types'; |
|||
import { SorterOptions } from './types'; |
|||
|
|||
export default class Sorter<T, NodeType extends SortableTreeNode<T>> { |
|||
em: EditorModel; |
|||
treeClass: new (model: T, content?: any) => NodeType; |
|||
placeholder: PlaceholderClass; |
|||
dropLocationDeterminer: DropLocationDeterminer<T, NodeType>; |
|||
|
|||
positionOptions: PositionOptions; |
|||
containerContext: SorterContainerContext; |
|||
dragBehavior: SorterDragBehaviorOptions; |
|||
eventHandlers: SorterEventHandlers<NodeType>; |
|||
sourceNodes?: NodeType[]; |
|||
constructor(sorterOptions: SorterOptions<T, NodeType>) { |
|||
const mergedOptions = getMergedOptions<T, NodeType>(sorterOptions); |
|||
|
|||
bindAll( |
|||
this, |
|||
'startSort', |
|||
'cancelDrag', |
|||
'recalculateTargetOnScroll', |
|||
'rollback', |
|||
'updateOffset', |
|||
'handlePlaceholderMove', |
|||
'finalizeMove', |
|||
); |
|||
this.containerContext = mergedOptions.containerContext; |
|||
this.positionOptions = mergedOptions.positionOptions; |
|||
this.dragBehavior = mergedOptions.dragBehavior; |
|||
this.eventHandlers = { |
|||
...mergedOptions.eventHandlers, |
|||
onPlaceholderPositionChange: this.handlePlaceholderMove, |
|||
onEnd: this.finalizeMove, |
|||
}; |
|||
|
|||
this.em = sorterOptions.em; |
|||
this.treeClass = sorterOptions.treeClass; |
|||
this.updateOffset(); |
|||
this.em.on(this.em.Canvas.events.refresh, this.updateOffset); |
|||
this.placeholder = this.createPlaceholder(); |
|||
|
|||
this.dropLocationDeterminer = new DropLocationDeterminer({ |
|||
em: this.em, |
|||
treeClass: this.treeClass, |
|||
containerContext: this.containerContext, |
|||
positionOptions: this.positionOptions, |
|||
dragDirection: this.dragBehavior.dragDirection, |
|||
eventHandlers: this.eventHandlers, |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Picking components to move |
|||
* @param {HTMLElement[]} sources[] |
|||
* */ |
|||
startSort(sources: { element?: HTMLElement; content?: any }[]) { |
|||
const validSources = sources.filter((source) => !!source.content || this.findValidSourceElement(source.element)); |
|||
|
|||
const sourcesWithModel: { model: T; content?: any }[] = validSources.map((source) => { |
|||
return { |
|||
model: $(source.element)?.data('model'), |
|||
content: source.content, |
|||
}; |
|||
}); |
|||
const sortedSources = sourcesWithModel.sort((a, b) => { |
|||
return sortDom(a.model, b.model); |
|||
}); |
|||
const sourceNodes = sortedSources.map((source) => new this.treeClass(source.model, source.content)); |
|||
this.sourceNodes = sourceNodes; |
|||
this.dropLocationDeterminer.startSort(sourceNodes); |
|||
this.bindDragEventHandlers(); |
|||
|
|||
this.eventHandlers.onStartSort?.(this.sourceNodes, this.containerContext.container); |
|||
|
|||
// For backward compatibility, leave it to a single node
|
|||
const model = this.sourceNodes[0]?.model; |
|||
this.eventHandlers.legacyOnStartSort?.({ |
|||
sorter: this, |
|||
target: model, |
|||
// @ts-ignore
|
|||
parent: model && model.parent?.(), |
|||
// @ts-ignore
|
|||
index: model && model.index?.(), |
|||
}); |
|||
|
|||
// For backward compatibility, leave it to a single node
|
|||
this.em.trigger('sorter:drag:start', sources[0], sourcesWithModel[0]); |
|||
} |
|||
|
|||
/** |
|||
* This method is should be called when the user scrolls within the container. |
|||
*/ |
|||
recalculateTargetOnScroll(): void { |
|||
this.dropLocationDeterminer.recalculateTargetOnScroll(); |
|||
} |
|||
|
|||
/** |
|||
* Called when the drag operation should be cancelled |
|||
*/ |
|||
cancelDrag(): void { |
|||
this.triggerNullOnEndMove(true); |
|||
this.dropLocationDeterminer.cancelDrag(); |
|||
} |
|||
|
|||
/** |
|||
* Called to drop an item onto a valid target. |
|||
*/ |
|||
endDrag() { |
|||
this.dropLocationDeterminer.endDrag(); |
|||
} |
|||
|
|||
private handlePlaceholderMove(elementDimension: Dimension, placement: Placement) { |
|||
this.ensurePlaceholderElement(); |
|||
this.updatePlaceholderPosition(elementDimension, placement); |
|||
} |
|||
|
|||
/** |
|||
* Creates a new placeholder element for the drag-and-drop operation. |
|||
* |
|||
* @returns {PlaceholderClass} The newly created placeholder instance. |
|||
*/ |
|||
private createPlaceholder(): PlaceholderClass { |
|||
return new PlaceholderClass({ |
|||
container: this.containerContext.container, |
|||
allowNesting: this.dragBehavior.nested, |
|||
pfx: this.containerContext.pfx, |
|||
el: this.containerContext.placeholderElement, |
|||
offset: { |
|||
top: this.positionOptions.offsetTop!, |
|||
left: this.positionOptions.offsetLeft!, |
|||
}, |
|||
}); |
|||
} |
|||
|
|||
private ensurePlaceholderElement() { |
|||
const el = this.placeholder.el; |
|||
const container = this.containerContext.container; |
|||
if (!el.ownerDocument.contains(el)) { |
|||
container.append(this.placeholder.el); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Triggered when the offset of the editor is changed |
|||
*/ |
|||
private updateOffset() { |
|||
const offset = this.em?.get('canvasOffset') || {}; |
|||
this.positionOptions.offsetTop = offset.top; |
|||
this.positionOptions.offsetLeft = offset.left; |
|||
} |
|||
|
|||
/** |
|||
* Finds the closest valid source element within the container context. |
|||
|
|||
* @param sourceElement - The initial source element to check. |
|||
* @returns The closest valid source element, or null if none is found. |
|||
*/ |
|||
private findValidSourceElement(sourceElement?: HTMLElement): HTMLElement | undefined { |
|||
if ( |
|||
sourceElement && |
|||
!matches(sourceElement, `${this.containerContext.itemSel}, ${this.containerContext.containerSel}`) |
|||
) { |
|||
sourceElement = closest(sourceElement, this.containerContext.itemSel)!; |
|||
} |
|||
|
|||
return sourceElement; |
|||
} |
|||
|
|||
private bindDragEventHandlers() { |
|||
on(this.containerContext.document, 'keydown', this.rollback); |
|||
} |
|||
|
|||
private updatePlaceholderPosition(targetDimension: Dimension, placement: Placement) { |
|||
this.placeholder.move(targetDimension, placement); |
|||
} |
|||
|
|||
/** |
|||
* Clean up event listeners that were attached during the move. |
|||
* |
|||
* @private |
|||
*/ |
|||
private cleanupEventListeners(): void { |
|||
off(this.containerContext.document, 'keydown', this.rollback); |
|||
} |
|||
|
|||
/** |
|||
* Finalize the move. |
|||
* |
|||
* @private |
|||
*/ |
|||
protected finalizeMove(): void { |
|||
this.cleanupEventListeners(); |
|||
this.placeholder.hide(); |
|||
delete this.sourceNodes; |
|||
} |
|||
|
|||
/** |
|||
* Cancels the drag on Escape press ( nothing is dropped or moved ) |
|||
* @param {KeyboardEvent} e - The keyboard event object. |
|||
*/ |
|||
private rollback(e: KeyboardEvent) { |
|||
off(this.containerContext.document, 'keydown', this.rollback); |
|||
const ESC_KEY = 'Escape'; |
|||
|
|||
if (e.key === ESC_KEY) { |
|||
this.cancelDrag(); |
|||
} |
|||
} |
|||
|
|||
// For the old sorter
|
|||
protected triggerNullOnEndMove(dragIsCancelled: boolean) { |
|||
const model = this.sourceNodes?.[0].model; |
|||
const data = { |
|||
target: model, |
|||
// @ts-ignore
|
|||
parent: model && model.parent?.(), |
|||
// @ts-ignore
|
|||
index: model && model.index?.(), |
|||
}; |
|||
|
|||
this.eventHandlers.legacyOnEndMove?.(null, this, { ...data, cancelled: dragIsCancelled }); |
|||
} |
|||
} |
|||
@ -0,0 +1,279 @@ |
|||
import { $, Model, SetOptions } from '../../common'; |
|||
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'; |
|||
|
|||
/** |
|||
* Find the position based on passed dimensions and coordinates |
|||
* @param {Array<Array>} dims Dimensions of nodes to parse |
|||
* @param {number} posX X coordindate |
|||
* @param {number} posY Y coordindate |
|||
* @return {Object} |
|||
* */ |
|||
export function findPosition(dims: Dimension[], posX: number, posY: number) { |
|||
const result = { index: 0, placement: 'before' as Placement }; |
|||
let leftLimit = 0; |
|||
let xLimit = 0; |
|||
let dimRight = 0; |
|||
let yLimit = 0; |
|||
let xCenter = 0; |
|||
let yCenter = 0; |
|||
let dimDown = 0; |
|||
let dim: Dimension; |
|||
|
|||
// Each dim is: Top, Left, Height, Width
|
|||
for (var i = 0, len = dims.length; i < len; i++) { |
|||
dim = dims[i]; |
|||
const { top, left, height, width } = dim; |
|||
// Right position of the element. Left + Width
|
|||
dimRight = left + width; |
|||
// Bottom position of the element. Top + Height
|
|||
dimDown = top + height; |
|||
// X center position of the element. Left + (Width / 2)
|
|||
xCenter = left + width / 2; |
|||
// Y center position of the element. Top + (Height / 2)
|
|||
yCenter = top + height / 2; |
|||
// Skip if over the limits
|
|||
if ( |
|||
(xLimit && left > xLimit) || |
|||
(yLimit && yCenter >= yLimit) || // >= avoid issue with clearfixes
|
|||
(leftLimit && dimRight < leftLimit) |
|||
) |
|||
continue; |
|||
result.index = i; |
|||
// If it's not in flow (like 'float' element)
|
|||
if (!dim.dir) { |
|||
if (posY < dimDown) yLimit = dimDown; |
|||
//If x lefter than center
|
|||
if (posX < xCenter) { |
|||
xLimit = xCenter; |
|||
result.placement = 'before'; |
|||
} else { |
|||
leftLimit = xCenter; |
|||
result.placement = 'after'; |
|||
} |
|||
} else { |
|||
// If y upper than center
|
|||
if (posY < yCenter) { |
|||
result.placement = 'before'; |
|||
break; |
|||
} else result.placement = 'after'; // After last element
|
|||
} |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
/** |
|||
* Get the offset of the element |
|||
* @param {HTMLElement} el |
|||
* @return {Object} |
|||
*/ |
|||
export function offset(el: HTMLElement) { |
|||
const rect = el.getBoundingClientRect(); |
|||
|
|||
return { |
|||
top: rect.top + document.body.scrollTop, |
|||
left: rect.left + document.body.scrollLeft, |
|||
}; |
|||
} |
|||
/** |
|||
* Returns true if the element matches with selector |
|||
* @param {Element} el |
|||
* @param {String} selector |
|||
* @return {Boolean} |
|||
*/ |
|||
export function matches(el: HTMLElement, selector: string): boolean { |
|||
return matchesMixin.call(el, selector); |
|||
} |
|||
|
|||
/** |
|||
* Sort according to the position in the dom |
|||
* @param {Object} model2 |
|||
* @param {Object} model1 |
|||
*/ |
|||
export function sortDom(model1: any, model2: any) { |
|||
const model1Parents = parents(model1); |
|||
const model2Parents = parents(model2); |
|||
// common ancesters
|
|||
const ancesters = model2Parents.filter((p: any) => model1Parents.includes(p)); |
|||
const ancester = ancesters[0]; |
|||
if (!ancester) { |
|||
// this is never supposed to happen
|
|||
return model1.model.index() - model2.model.index(); |
|||
} |
|||
// find siblings in the common ancester
|
|||
// the sibling is the element inside the ancester
|
|||
const s1 = model2Parents[model2Parents.indexOf(ancester) - 1]; |
|||
const s2 = model1Parents[model1Parents.indexOf(ancester) - 1]; |
|||
// order according to the position in the DOM
|
|||
return s2.index() - s1.index(); |
|||
} |
|||
/** |
|||
* Build an array of all the parents, including the component itself |
|||
* @return {Model|null} |
|||
*/ |
|||
function parents(model: any): any[] { |
|||
return model ? [model].concat(parents(model.parent())) : []; |
|||
} |
|||
|
|||
/** |
|||
* Closest parent |
|||
* @param {Element} el |
|||
* @param {String} selector |
|||
* @return {Element|null} |
|||
*/ |
|||
export function closest(el: HTMLElement, selector: string): HTMLElement | undefined { |
|||
if (!el) return; |
|||
let elem = el.parentNode; |
|||
|
|||
while (elem && elem.nodeType === 1) { |
|||
if (matches(elem as HTMLElement, selector)) return elem as HTMLElement; |
|||
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. |
|||
* Considers properties like `float`, `position`, and certain display types. |
|||
* |
|||
* @param {HTMLElement} el - The element to check. |
|||
* @param {HTMLElement} parent - The parent element for additional style checks. |
|||
* @return {boolean} Returns `true` if the element is styled to be in flow, otherwise `false`. |
|||
* @private |
|||
*/ |
|||
function isStyleInFlow(el: HTMLElement, parent: HTMLElement): boolean { |
|||
if (isTextNode(el)) return false; |
|||
|
|||
const elementStyles = el.style || {}; |
|||
const $el = $(el); |
|||
const $parent = $(parent); |
|||
|
|||
// Check overflow property
|
|||
if (elementStyles.overflow && elementStyles.overflow !== 'visible') return false; |
|||
|
|||
// Check float property
|
|||
const elementFloat = $el.css('float'); |
|||
if (elementFloat && elementFloat !== 'none') return false; |
|||
|
|||
// Check parent for flexbox display and non-column flex-direction
|
|||
if ($parent.css('display') === 'flex' && $parent.css('flex-direction') !== 'column') return false; |
|||
|
|||
// Check position property
|
|||
if (!isInFlowPosition(elementStyles.position)) return false; |
|||
|
|||
// Check tag and display properties
|
|||
return isFlowElementTag(el) || isFlowElementDisplay($el); |
|||
} |
|||
|
|||
/** |
|||
* Determines if the element's `position` style keeps it in the flow. |
|||
* |
|||
* @param {string} position - The position style of the element. |
|||
* @return {boolean} Returns `true` if the position keeps the element in flow. |
|||
* @private |
|||
*/ |
|||
function isInFlowPosition(position: string): boolean { |
|||
switch (position) { |
|||
case 'static': |
|||
case 'relative': |
|||
case '': |
|||
return true; |
|||
default: |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Checks if the element's tag name represents an element typically in flow. |
|||
* |
|||
* @param {HTMLElement} el - The element to check. |
|||
* @return {boolean} Returns `true` if the tag name represents a flow element. |
|||
* @private |
|||
*/ |
|||
function isFlowElementTag(el: HTMLElement): boolean { |
|||
const flowTags = ['TR', 'TBODY', 'THEAD', 'TFOOT']; |
|||
return flowTags.includes(el.tagName); |
|||
} |
|||
|
|||
/** |
|||
* Checks if the element's display style keeps it in flow. |
|||
* |
|||
* @param {JQuery} $el - The jQuery-wrapped element to check. |
|||
* @return {boolean} Returns `true` if the display style represents a flow element. |
|||
* @private |
|||
*/ |
|||
function isFlowElementDisplay($el: JQuery): boolean { |
|||
const display = $el.css('display'); |
|||
const flowDisplays = ['block', 'list-item', 'table', 'flex', 'grid']; |
|||
return flowDisplays.includes(display); |
|||
} |
|||
|
|||
export function getDocument(em?: EditorModel, el?: HTMLElement) { |
|||
const elDoc = el ? el.ownerDocument : em?.Canvas.getBody().ownerDocument; |
|||
return elDoc; |
|||
} |
|||
|
|||
export function getMergedOptions<T, NodeType extends SortableTreeNode<T>>(sorterOptions: SorterOptions<T, NodeType>) { |
|||
const defaultOptions = { |
|||
containerContext: { |
|||
container: '' as any, |
|||
placeholderElement: '' as any, |
|||
containerSel: '*', |
|||
itemSel: '*', |
|||
pfx: '', |
|||
document, |
|||
}, |
|||
positionOptions: { |
|||
borderOffset: 10, |
|||
relative: false, |
|||
windowMargin: 0, |
|||
offsetTop: 0, |
|||
offsetLeft: 0, |
|||
scale: 1, |
|||
canvasRelative: false, |
|||
}, |
|||
dragBehavior: { |
|||
dragDirection: DragDirection.Vertical, |
|||
nested: false, |
|||
selectOnEnd: true, |
|||
}, |
|||
eventHandlers: {}, |
|||
}; |
|||
|
|||
const mergedOptions = { |
|||
...defaultOptions, |
|||
...sorterOptions, |
|||
containerContext: { |
|||
...defaultOptions.containerContext, |
|||
...sorterOptions.containerContext, |
|||
}, |
|||
positionOptions: { |
|||
...defaultOptions.positionOptions, |
|||
...sorterOptions.positionOptions, |
|||
}, |
|||
dragBehavior: { |
|||
...defaultOptions.dragBehavior, |
|||
...sorterOptions.dragBehavior, |
|||
}, |
|||
eventHandlers: { |
|||
...defaultOptions.eventHandlers, |
|||
...sorterOptions.eventHandlers, |
|||
}, |
|||
}; |
|||
|
|||
return mergedOptions; |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
import EditorModel from '../../editor/model/Editor'; |
|||
import Layer from '../../style_manager/model/Layer'; |
|||
import Layers from '../../style_manager/model/Layers'; |
|||
import { LayerNode } from './LayerNode'; |
|||
import Sorter from './Sorter'; |
|||
import { SorterContainerContext, PositionOptions, SorterDragBehaviorOptions, SorterEventHandlers } from './types'; |
|||
|
|||
export default class StyleManagerSorter extends Sorter<Layers | Layer, LayerNode> { |
|||
constructor({ |
|||
em, |
|||
containerContext, |
|||
dragBehavior, |
|||
positionOptions = {}, |
|||
eventHandlers = {}, |
|||
}: { |
|||
em: EditorModel; |
|||
containerContext: SorterContainerContext; |
|||
dragBehavior: SorterDragBehaviorOptions; |
|||
positionOptions?: PositionOptions; |
|||
eventHandlers?: SorterEventHandlers<LayerNode>; |
|||
}) { |
|||
super({ |
|||
em, |
|||
treeClass: LayerNode, |
|||
containerContext, |
|||
positionOptions, |
|||
dragBehavior, |
|||
eventHandlers: { |
|||
onStartSort: (sourceNodes: LayerNode[], containerElement?: HTMLElement) => { |
|||
eventHandlers.onStartSort?.(sourceNodes, containerElement); |
|||
this.onLayerStartSort(sourceNodes); |
|||
}, |
|||
onDrop: (targetNode: LayerNode | undefined, sourceNodes: LayerNode[], index: number | undefined) => { |
|||
eventHandlers.onDrop?.(targetNode, sourceNodes, index); |
|||
this.onLayerDrop(targetNode, sourceNodes, index); |
|||
}, |
|||
onEnd: () => { |
|||
this.placeholder.hide(); |
|||
}, |
|||
...eventHandlers, |
|||
}, |
|||
}); |
|||
} |
|||
|
|||
onLayerStartSort = (sourceNodes: LayerNode[]) => { |
|||
this.em.clearSelection(); |
|||
|
|||
// For backward compatibility, leave it to a single node
|
|||
const sourceNode = sourceNodes[0]; |
|||
this.em.trigger('sorter:drag:start', sourceNode?.element, sourceNode?.model); |
|||
this.placeholder.show(); |
|||
}; |
|||
|
|||
onLayerDrop = (targetNode: LayerNode | undefined, sourceNodes: LayerNode[], index: number | undefined) => { |
|||
if (!targetNode) { |
|||
return; |
|||
} |
|||
index = typeof index === 'number' ? index : -1; |
|||
for (let idx = 0; idx < sourceNodes.length; idx++) { |
|||
const sourceNode = sourceNodes[idx]; |
|||
if (!targetNode.canMove(sourceNode, idx)) { |
|||
continue; |
|||
} |
|||
const parent = sourceNode.getParent(); |
|||
let initialSourceIndex = -1; |
|||
if (parent) { |
|||
initialSourceIndex = parent.indexOfChild(sourceNode); |
|||
parent.removeChildAt(initialSourceIndex); |
|||
} |
|||
index = initialSourceIndex < index ? index - 1 : index; |
|||
|
|||
targetNode.addChildAt(sourceNode, index); |
|||
} |
|||
this.placeholder.hide(); |
|||
}; |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
import CanvasModule from '../../canvas'; |
|||
import EditorModel from '../../editor/model/Editor'; |
|||
import { SortableTreeNode } from './SortableTreeNode'; |
|||
|
|||
export interface Dimension { |
|||
top: number; |
|||
left: number; |
|||
height: number; |
|||
width: number; |
|||
offsets: ReturnType<CanvasModule['getElementOffsets']>; |
|||
dir?: boolean; |
|||
el?: HTMLElement; |
|||
indexEl?: number; |
|||
} |
|||
|
|||
export type Placement = 'inside' | 'before' | 'after'; |
|||
|
|||
export enum DragDirection { |
|||
Vertical = 'Vertical', |
|||
Horizontal = 'Horizontal', |
|||
BothDirections = 'BothDirections', |
|||
} |
|||
|
|||
export type CustomTarget = ({ event }: { event: MouseEvent }) => HTMLElement | null; |
|||
|
|||
export interface SorterContainerContext { |
|||
container: HTMLElement; |
|||
containerSel: string; |
|||
itemSel: string; |
|||
pfx: string; |
|||
document: Document; |
|||
placeholderElement: HTMLElement; |
|||
customTarget?: CustomTarget; |
|||
} |
|||
|
|||
export interface PositionOptions { |
|||
windowMargin?: number; |
|||
borderOffset?: number; |
|||
offsetTop?: number; |
|||
offsetLeft?: number; |
|||
canvasRelative?: boolean; |
|||
relative?: boolean; |
|||
} |
|||
|
|||
/** |
|||
* Represents an event handler for the `onStartSort` event. |
|||
* |
|||
* @param sourceNodes The source nodes being sorted. |
|||
* @param container The container element where the sorting is taking place. |
|||
*/ |
|||
type OnStartSortHandler<NodeType> = (sourceNodes: NodeType[], container?: HTMLElement) => void; |
|||
|
|||
/** |
|||
* Represents an event handler for the `onDragStart` event. |
|||
* |
|||
* @param mouseEvent The mouse event associated with the drag start. |
|||
*/ |
|||
type OnDragStartHandler = (mouseEvent: MouseEvent) => void; |
|||
type OnMouseMoveHandler = (mouseEvent: MouseEvent) => void; |
|||
type OnDropHandler<NodeType> = ( |
|||
targetNode: NodeType | undefined, |
|||
sourceNodes: NodeType[], |
|||
index: number | undefined, |
|||
) => void; |
|||
type OnTargetChangeHandler<NodeType> = ( |
|||
oldTargetNode: NodeType | undefined, |
|||
newTargetNode: NodeType | undefined, |
|||
) => void; |
|||
type OnPlaceholderPositionChangeHandler = (targetDimension: Dimension, placement: Placement) => void; |
|||
type OnEndHandler = () => void; |
|||
|
|||
/** |
|||
* Represents a collection of event handlers for sortable tree node events. |
|||
*/ |
|||
export interface SorterEventHandlers<NodeType> { |
|||
onStartSort?: OnStartSortHandler<NodeType>; |
|||
onDragStart?: OnDragStartHandler; |
|||
onMouseMove?: OnMouseMoveHandler; |
|||
onDrop?: OnDropHandler<NodeType>; |
|||
onTargetChange?: OnTargetChangeHandler<NodeType>; |
|||
onPlaceholderPositionChange?: OnPlaceholderPositionChangeHandler; |
|||
onEnd?: OnEndHandler; |
|||
|
|||
// For compatibility with old sorter
|
|||
legacyOnMoveClb?: Function; |
|||
legacyOnStartSort?: Function; |
|||
legacyOnEndMove?: Function; |
|||
legacyOnEnd?: Function; |
|||
} |
|||
|
|||
export interface SorterDragBehaviorOptions { |
|||
dragDirection: DragDirection; |
|||
nested?: boolean; |
|||
selectOnEnd?: boolean; |
|||
} |
|||
|
|||
export interface SorterOptions<T, NodeType extends SortableTreeNode<T>> { |
|||
em: EditorModel; |
|||
treeClass: new (model: T, content?: any) => NodeType; |
|||
|
|||
containerContext: SorterContainerContext; |
|||
positionOptions: PositionOptions; |
|||
dragBehavior: SorterDragBehaviorOptions; |
|||
eventHandlers: SorterEventHandlers<NodeType>; |
|||
} |
|||
Loading…
Reference in new issue