diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 6c6ef60..e0365cb 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -2,7 +2,9 @@ import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { Engine } from '@/app/core/models'; +import { createDesigner } from '@/app/core/externals'; export const appConfig: ApplicationConfig = { - providers: [provideRouter(routes)] + providers: [provideRouter(routes), { provide: Engine, useFactory: createDesigner }] }; diff --git a/src/app/core/events/mutation/DropNodeEvent.ts b/src/app/core/events/mutation/DropNodeEvent.ts new file mode 100644 index 0000000..d8e9ef8 --- /dev/null +++ b/src/app/core/events/mutation/DropNodeEvent.ts @@ -0,0 +1,6 @@ +import { AbstractMutationNodeEvent } from './AbstractMutationNodeEvent'; +import { ICustomEvent } from '../../../shared/event'; + +export class DropNodeEvent extends AbstractMutationNodeEvent implements ICustomEvent { + type = 'drop:node'; +} diff --git a/src/app/core/events/mutation/SelectNodeEvent.ts b/src/app/core/events/mutation/SelectNodeEvent.ts new file mode 100644 index 0000000..b2c93cf --- /dev/null +++ b/src/app/core/events/mutation/SelectNodeEvent.ts @@ -0,0 +1,6 @@ +import { AbstractMutationNodeEvent } from './AbstractMutationNodeEvent'; +import { ICustomEvent } from '../../../shared/event'; + +export class SelectNodeEvent extends AbstractMutationNodeEvent implements ICustomEvent { + type = 'select:node'; +} diff --git a/src/app/core/events/mutation/index.ts b/src/app/core/events/mutation/index.ts index 79b169f..bbfef65 100644 --- a/src/app/core/events/mutation/index.ts +++ b/src/app/core/events/mutation/index.ts @@ -10,3 +10,4 @@ export * from './UpdateChildrenEvent'; export * from './RemoveNodeEvent'; export * from './CloneNodeEvent'; export * from './FromNodeEvent'; +export * from './DropNodeEvent'; diff --git a/src/app/core/externals.ts b/src/app/core/externals.ts index 8e7fbf7..c34c66a 100644 --- a/src/app/core/externals.ts +++ b/src/app/core/externals.ts @@ -1,5 +1,6 @@ import { IBehavior, IBehaviorHost, IResource, IResourceCreator } from './types'; import { Engine } from '@/app/core/models'; +import { DEFAULT_DRIVERS, DEFAULT_EFFECTS } from '@/app/core/presets'; export const createResource = (...sources: IResourceCreator[]): IResource[] => { return sources.reduce((buf, source) => { @@ -26,5 +27,8 @@ export const isBehaviorHost = (val: any): val is IBehaviorHost => val?.Behavior export const isBehaviorList = (val: any): val is IBehavior[] => Array.isArray(val) && val.every(isBehavior); export const createDesigner = () => { - // return new Engine(); + return new Engine({ + effects: [...DEFAULT_EFFECTS], + drivers: [...DEFAULT_DRIVERS] + }); }; diff --git a/src/app/core/models/move-helper.ts b/src/app/core/models/move-helper.ts index 0f1e735..d03a0d7 100644 --- a/src/app/core/models/move-helper.ts +++ b/src/app/core/models/move-helper.ts @@ -1,9 +1,16 @@ import { Operation } from './operation'; import { TreeNode } from './tree-node'; import { Viewport } from './viewport'; -import { IPoint, Rect } from '../../shared/coordinate'; +import { + calcDistanceOfPointToRect, + calcDistancePointToEdge, + IPoint, + isNearAfter, + isPointInRect, + Rect +} from '../../shared/coordinate'; import { CursorDragType } from '@/app/core/models/cursor'; -import { DragNodeEvent } from '@/app/core/events'; +import { DragNodeEvent, DropNodeEvent } from '@/app/core/events'; export enum ClosestPosition { Before = 'BEFORE', @@ -94,6 +101,153 @@ export class MoveHelper { return this.viewportClosestDirection; } + getClosestLayout(viewport: Viewport) { + return viewport.getValidNodeLayout(this.closestNode); + } + + calcClosestPosition(point: IPoint, viewport: Viewport): ClosestPosition { + const closestNode = this.closestNode; + if (!closestNode || !viewport.isPointInViewport(point)) return ClosestPosition.Forbid; + const closestRect = viewport.getValidNodeRect(closestNode); + const isInline = this.getClosestLayout(viewport) === 'horizontal'; + if (!closestRect) { + return null; + } + const isAfter = isNearAfter(point, closestRect, viewport.moveInsertionType === 'block' ? false : isInline); + const getValidParent = (node: TreeNode) => { + if (!node) return; + if (node.parent?.allowSibling(this.dragNodes)) return node.parent; + return getValidParent(node.parent); + }; + if (isPointInRect(point, closestRect, viewport.moveSensitive)) { + if (!closestNode.allowAppend(this.dragNodes)) { + if (!closestNode.allowSibling(this.dragNodes)) { + const parentClosestNode = getValidParent(closestNode); + if (parentClosestNode) { + this.closestNode = parentClosestNode; + } + if (isInline) { + if (parentClosestNode) { + if (isAfter) { + return ClosestPosition.After; + } + return ClosestPosition.Before; + } + if (isAfter) { + return ClosestPosition.ForbidAfter; + } + return ClosestPosition.ForbidBefore; + } else { + if (parentClosestNode) { + if (isAfter) { + return ClosestPosition.Under; + } + return ClosestPosition.Upper; + } + if (isAfter) { + return ClosestPosition.ForbidUnder; + } + return ClosestPosition.ForbidUpper; + } + } else { + if (isInline) { + return isAfter ? ClosestPosition.After : ClosestPosition.Before; + } else { + return isAfter ? ClosestPosition.Under : ClosestPosition.Upper; + } + } + } + if (closestNode.contains(...this.dragNodes)) { + if (isAfter) { + return ClosestPosition.InnerAfter; + } + return ClosestPosition.InnerBefore; + } else { + return ClosestPosition.Inner; + } + } else if (closestNode === closestNode.root) { + return isAfter ? ClosestPosition.InnerAfter : ClosestPosition.InnerBefore; + } else { + if (!closestNode.allowSibling(this.dragNodes)) { + const parentClosestNode = getValidParent(closestNode); + if (parentClosestNode) { + this.closestNode = parentClosestNode; + } + if (isInline) { + if (parentClosestNode) { + if (isAfter) { + return ClosestPosition.After; + } + return ClosestPosition.Before; + } + return isAfter ? ClosestPosition.ForbidAfter : ClosestPosition.ForbidBefore; + } else { + if (parentClosestNode) { + if (isAfter) { + return ClosestPosition.Under; + } + return ClosestPosition.Upper; + } + return isAfter ? ClosestPosition.ForbidUnder : ClosestPosition.ForbidUpper; + } + } + if (isInline) { + return isAfter ? ClosestPosition.After : ClosestPosition.Before; + } else { + return isAfter ? ClosestPosition.Under : ClosestPosition.Upper; + } + } + } + + calcClosestNode(point: IPoint, viewport: Viewport): TreeNode { + if (this.touchNode) { + const touchNodeRect = viewport.getValidNodeRect(this.touchNode); + if (!touchNodeRect) return null; + if (this.touchNode?.children?.length) { + const touchDistance = calcDistancePointToEdge(point, touchNodeRect); + let minDistance = touchDistance; + let minDistanceNode = this.touchNode; + this.touchNode.eachChildren(node => { + const rect = viewport.getElementRectById(node.id); + if (!rect) return; + const distance = isPointInRect(point, rect, viewport.moveSensitive) + ? 0 + : calcDistanceOfPointToRect(point, rect); + if (distance <= minDistance) { + minDistance = distance; + minDistanceNode = node; + } + }); + return minDistanceNode; + } else { + return this.touchNode; + } + } + return this.operation.tree; + } + + calcClosestRect(viewport: Viewport, closestDirection: ClosestPosition): Rect { + const closestNode = this.closestNode; + if (!closestNode || !closestDirection) return null; + const closestRect = viewport.getValidNodeRect(closestNode); + if (closestDirection === ClosestPosition.InnerAfter || closestDirection === ClosestPosition.InnerBefore) { + return viewport.getChildrenRect(closestNode); + } else { + return closestRect; + } + } + + calcClosestOffsetRect(viewport: Viewport, closestDirection: ClosestPosition): Rect { + const closestNode = this.closestNode; + if (!closestNode || !closestDirection) return null; + const closestRect = viewport.getValidNodeOffsetRect(closestNode); + if (closestDirection === ClosestPosition.InnerAfter || closestDirection === ClosestPosition.InnerBefore) { + return viewport.getChildrenOffsetRect(closestNode); + } else { + return closestRect; + } + } + dragStart(props: IMoveHelperDragStartProps) { const nodes = TreeNode.filterDraggable(props?.dragNodes); if (nodes.length) { @@ -110,6 +264,61 @@ export class MoveHelper { } } + dragMove(props: IMoveHelperDragMoveProps) { + const { point, touchNode } = props; + if (!this.dragging) return; + if (this.outline.isPointInViewport(point, false)) { + this.activeViewport = this.outline; + this.touchNode = touchNode; + this.closestNode = this.calcClosestNode(point, this.outline); + } else if (this.viewport.isPointInViewport(point, false)) { + this.activeViewport = this.viewport; + this.touchNode = touchNode; + this.closestNode = this.calcClosestNode(point, this.viewport); + } + if (!this.activeViewport) return; + + if (this.activeViewport === this.outline) { + this.outlineClosestDirection = this.calcClosestPosition(point, this.outline); + this.viewportClosestDirection = this.outlineClosestDirection; + } else { + this.viewportClosestDirection = this.calcClosestPosition(point, this.viewport); + this.outlineClosestDirection = this.viewportClosestDirection; + } + if (this.outline.mounted) { + this.outlineClosestRect = this.calcClosestRect(this.outline, this.outlineClosestDirection); + this.outlineClosestOffsetRect = this.calcClosestOffsetRect(this.outline, this.outlineClosestDirection); + } + if (this.viewport.mounted) { + this.viewportClosestRect = this.calcClosestRect(this.viewport, this.viewportClosestDirection); + this.viewportClosestOffsetRect = this.calcClosestOffsetRect(this.viewport, this.viewportClosestDirection); + } + } + + dragDrop(props: IMoveHelperDragDropProps) { + this.trigger( + new DropNodeEvent({ + target: this.operation.tree, + source: props?.dropNode + }) + ); + } + + dragEnd() { + this.dragging = false; + this.dragNodes = []; + this.touchNode = null; + this.closestNode = null; + this.activeViewport = null; + this.outlineClosestDirection = null; + this.outlineClosestOffsetRect = null; + this.outlineClosestRect = null; + this.viewportClosestDirection = null; + this.viewportClosestOffsetRect = null; + this.viewportClosestRect = null; + this.viewport.clearCache(); + } + trigger(event: any) { if (this.operation) { return this.operation.dispatch(event); diff --git a/src/app/core/models/selection.ts b/src/app/core/models/selection.ts index 5e1a011..745af22 100644 --- a/src/app/core/models/selection.ts +++ b/src/app/core/models/selection.ts @@ -1,4 +1,7 @@ import { Operation } from './operation'; +import { TreeNode } from '@/app/core/models/tree-node'; +import { isArr, isStr } from '@/app/shared/types'; +import { SelectNodeEvent } from '@/app/core/events/mutation/SelectNodeEvent'; export interface ISelection { selected?: string[]; @@ -9,7 +12,34 @@ export class Selection { selected: string[] = []; indexes: Record = {}; + trigger(type = SelectNodeEvent) { + return this.operation.dispatch( + new type({ + target: this.operation.tree, + source: this.selectedNodes + }) + ); + } + get selectedNodes() { return this.selected.map(id => this.operation.tree.findById(id)); } + + mapIds(ids: any) { + return isArr(ids) ? ids.map((node: any) => (isStr(node) ? node : node?.id)) : []; + } + + batchSelect(ids: string[] | TreeNode[]) { + this.selected = this.mapIds(ids); + this.indexes = this.selected.reduce((buf, id) => { + buf[id] = true; + return buf; + }, {}); + this.trigger(SelectNodeEvent); + } + + batchSafeSelect(ids: string[] | TreeNode[]) { + if (!ids?.length) return; + this.batchSelect(ids); + } } diff --git a/src/app/core/models/viewport.ts b/src/app/core/models/viewport.ts index 2cde9f3..ef642a1 100644 --- a/src/app/core/models/viewport.ts +++ b/src/app/core/models/viewport.ts @@ -1,6 +1,9 @@ import { Workspace } from './workspace'; import { Engine } from './engine'; import { globalThisPolyfill } from '@/app/shared/globalThisPolyfill'; +import { calcBoundingRect, IPoint, isPointInRect, Rect } from '@/app/shared/coordinate'; +import { TreeNode } from '@/app/core/models/tree-node'; +import { calcElementLayout } from '@/app/shared/element'; export interface IViewportProps { engine: Engine; @@ -72,4 +75,178 @@ export class Viewport { get isIframe() { return !!this.contentWindow?.frameElement && !this.isMaster; } + + get rect() { + const viewportElement = this.viewportElement; + if (viewportElement) return viewportElement.getBoundingClientRect(); + return null; + } + + get innerRect() { + const rect = this.rect; + return new Rect(0, 0, rect?.width, rect?.height); + } + + get offsetX() { + const rect = this.rect; + if (!rect) return 0; + return rect.x; + } + + get offsetY() { + const rect = this.rect; + if (!rect) return 0; + return rect.y; + } + + get scale() { + if (!this.viewportElement) return 1; + const clientRect = this.viewportElement.getBoundingClientRect(); + const offsetWidth = this.viewportElement.offsetWidth; + if (!clientRect.width || !offsetWidth) return 1; + return Math.round(clientRect.width / offsetWidth); + } + + clearCache() { + this.nodeElementsStore = {}; + } + + isPointInViewport(point: IPoint, sensitive?: boolean) { + if (!this.rect) return false; + if (!this.containsElement(document.elementFromPoint(point.x, point.y))) { + return false; + } + return isPointInRect(point, this.rect, sensitive); + } + + containsElement(element: HTMLElement | Element | EventTarget) { + const root: Element | HTMLDocument = this.viewportElement; + if (root === element) return true; + return root?.contains(element as any); + } + + getValidNodeRect(node: TreeNode): Rect { + if (!node) return null; + const rect = this.getElementRectById(node.id); + if (node && node === node.root && node.isInOperation) { + if (!rect) return this.rect; + return calcBoundingRect([this.rect, rect]); + } + + if (rect) { + return rect; + } else { + return this.getChildrenRect(node); + } + } + + getValidNodeOffsetRect(node: TreeNode): Rect { + if (!node) return null; + const rect = this.getElementOffsetRectById(node.id); + if (node && node === node.root && node.isInOperation) { + if (!rect) return this.innerRect; + return calcBoundingRect([this.innerRect, rect]); + } + if (rect) { + return rect; + } else { + return this.getChildrenOffsetRect(node); + } + } + + getChildrenOffsetRect(node: TreeNode): Rect { + if (!node?.children?.length) return null; + + return calcBoundingRect( + node.children.reduce((buf, child) => { + const rect = this.getValidNodeOffsetRect(child); + if (rect) { + return buf.concat(rect); + } + return buf; + }, []) + ); + } + + //相对于视口 + getElementOffsetRectById(id: string) { + const elements = this.findElementsById(id); + if (!elements.length) return null; + const elementRect = calcBoundingRect(elements.map(element => this.getElementRect(element))); + if (elementRect) { + if (this.isIframe) { + return new Rect( + elementRect.x + this.contentWindow.scrollX, + elementRect.y + this.contentWindow.scrollY, + elementRect.width, + elementRect.height + ); + } else { + return new Rect( + (elementRect.x - this.offsetX + this.viewportElement.scrollLeft) / this.scale, + (elementRect.y - this.offsetY + this.viewportElement.scrollTop) / this.scale, + elementRect.width, + elementRect.height + ); + } + } + return null; + } + + getChildrenRect(node: TreeNode): Rect { + if (!node?.children?.length) return null; + return calcBoundingRect( + node.children.reduce((buf, child) => { + const rect = this.getValidNodeRect(child); + if (rect) { + return buf.concat(rect); + } + return buf; + }, []) + ); + } + + getElementRectById(id: string) { + const elements = this.findElementsById(id); + const rect = calcBoundingRect(elements.map(element => this.getElementRect(element))); + if (rect) { + if (this.isIframe) { + return new Rect(rect.x + this.offsetX, rect.y + this.offsetY, rect.width, rect.height); + } else { + return new Rect(rect.x, rect.y, rect.width, rect.height); + } + } + return null; + } + + findElementById(id: string): HTMLElement { + if (!id) return null; + if (this.nodeElementsStore[id]) return this.nodeElementsStore[id][0]; + return this.viewportRoot?.querySelector(`*[${this.nodeIdAttrName}='${id}']`) as HTMLElement; + } + + findElementsById(id: string): HTMLElement[] { + if (!id) return []; + if (this.nodeElementsStore[id]) return this.nodeElementsStore[id]; + return Array.from(this.viewportRoot?.querySelectorAll(`*[${this.nodeIdAttrName}='${id}']`) ?? []); + } + + //相对于页面 + getElementRect(element: HTMLElement | Element) { + const rect = element.getBoundingClientRect(); + const offsetWidth = element['offsetWidth'] ? element['offsetWidth'] : rect.width; + const offsetHeight = element['offsetHeight'] ? element['offsetHeight'] : rect.height; + return new Rect( + rect.x, + rect.y, + this.scale !== 1 ? offsetWidth : rect.width, + this.scale !== 1 ? offsetHeight : rect.height + ); + } + + getValidNodeLayout(node: TreeNode) { + if (!node) return 'vertical'; + if (node.parent?.designerProps?.inlineChildrenLayout) return 'horizontal'; + return calcElementLayout(this.findElementById(node.id)); + } } diff --git a/src/app/core/presets.ts b/src/app/core/presets.ts index 53a1d4a..310c20c 100644 --- a/src/app/core/presets.ts +++ b/src/app/core/presets.ts @@ -1,3 +1,6 @@ import { useDragDropEffect } from './effects/useDragDropEffect'; +import { DragDropDriver } from '@/app/core/drivers/drag-drop-driver'; export const DEFAULT_EFFECTS = [useDragDropEffect]; + +export const DEFAULT_DRIVERS = [DragDropDriver]; diff --git a/src/app/shared/coordinate.ts b/src/app/shared/coordinate.ts index 677e717..754cec6 100644 --- a/src/app/shared/coordinate.ts +++ b/src/app/shared/coordinate.ts @@ -57,3 +57,76 @@ export class Point implements IPoint { this.y = y; } } + +export function isPointInRect(point: IPoint, rect: IRect, sensitive = true) { + const boundSensor = (value: number) => { + if (!sensitive) return 0; + const sensor = value * 0.1; + if (sensor > 20) return 20; + if (sensor < 10) return 10; + return sensor; + }; + + return ( + point.x >= rect.x + boundSensor(rect.width) && + point.x <= rect.x + rect.width - boundSensor(rect.width) && + point.y >= rect.y + boundSensor(rect.height) && + point.y <= rect.y + rect.height - boundSensor(rect.height) + ); +} + +export function calcDistanceOfPointToRect(point: IPoint, rect: IRect) { + let minX = Math.min(Math.abs(point.x - rect.x), Math.abs(point.x - (rect.x + rect.width))); + let minY = Math.min(Math.abs(point.y - rect.y), Math.abs(point.y - (rect.y + rect.height))); + if (point.x >= rect.x && point.x <= rect.x + rect.width) { + minX = 0; + } + if (point.y >= rect.y && point.y <= rect.y + rect.height) { + minY = 0; + } + + return Math.sqrt(minX ** 2 + minY ** 2); +} + +export function calcDistancePointToEdge(point: IPoint, rect: IRect) { + const distanceTop = Math.abs(point.y - rect.y); + const distanceBottom = Math.abs(point.y - (rect.y + rect.height)); + const distanceLeft = Math.abs(point.x - rect.x); + const distanceRight = Math.abs(point.x - (rect.x + rect.width)); + return Math.min(distanceTop, distanceBottom, distanceLeft, distanceRight); +} + +export function isNearAfter(point: IPoint, rect: IRect, inline = false) { + if (inline) { + return ( + Math.abs(point.x - rect.x) + Math.abs(point.y - rect.y) > + Math.abs(point.x - (rect.x + rect.width)) + Math.abs(point.y - (rect.y + rect.height)) + ); + } + return Math.abs(point.y - rect.y) > Math.abs(point.y - (rect.y + rect.height)); +} + +export function calcBoundingRect(rects: IRect[]) { + if (!rects?.length) return null; + if (rects?.length === 1 && !rects[0]) return null; + let minTop = Infinity; + let maxBottom = -Infinity; + let minLeft = Infinity; + let maxRight = -Infinity; + rects.forEach(item => { + const rect = new Rect(item.x, item.y, item.width, item.height); + if (rect.top <= minTop) { + minTop = rect.top; + } + if (rect.bottom >= maxBottom) { + maxBottom = rect.bottom; + } + if (rect.left <= minLeft) { + minLeft = rect.left; + } + if (rect.right >= maxRight) { + maxRight = rect.right; + } + }); + return new Rect(minLeft, minTop, maxRight - minLeft, maxBottom - minTop); +} diff --git a/src/app/shared/element.ts b/src/app/shared/element.ts new file mode 100644 index 0000000..8f2e066 --- /dev/null +++ b/src/app/shared/element.ts @@ -0,0 +1,106 @@ +const InlineLayoutTagNames = new Set([ + 'A', + 'ABBR', + 'ACRONYM', + 'AUDIO', + 'B', + 'BDI', + 'BDO', + 'BIG', + 'BR', + 'BUTTON', + 'CANVAS', + 'CITE', + 'CODE', + 'DATA', + 'DATALIST', + 'DEL', + 'DFN', + 'EM', + 'EMBED', + 'I', + 'IFRAME', + 'IMG', + 'INS', + 'KBD', + 'LABEL', + 'MAP', + 'MARK', + 'METER', + 'NOSCRIPT', + 'OBJECT', + 'OUTPUT', + 'PICTURE', + 'PROGRESS', + 'Q', + 'RUBY', + 'S', + 'SAMP', + 'SELECT', + 'SLOT', + 'SMALL', + 'STRONG', + 'SUB', + 'SUP', + 'SVG', + 'TEMPLATE', + 'TEXTAREA', + 'TIME', + 'U', + 'TT', + 'VAR', + 'VIDEO', + 'WBR', + 'INPUT', + 'SPAN' +]); + +export const calcElementOuterWidth = (innerWidth: number, style: CSSStyleDeclaration) => { + return ( + innerWidth + + parseFloat(style.marginLeft) + + parseFloat(style.marginRight) + + parseFloat(style.paddingLeft) + + parseFloat(style.paddingRight) + + parseFloat(style.borderLeftWidth) + + parseFloat(style.borderRightWidth) + ); +}; + +export const calcElementLayout = (element: Element) => { + if (!element) return 'vertical'; + const parent = element.parentElement; + if (!parent) return 'vertical'; + const tagName = element.tagName; + const parentTagName = parent.tagName; + const style = getComputedStyle(element); + const parentStyle = getComputedStyle(parent); + + const isNotFullWidth = () => { + const innerWidth = element.getBoundingClientRect().width; + const outerWidth = calcElementOuterWidth(innerWidth, style); + const parentInnerWidth = parent.getBoundingClientRect().width; + return outerWidth.toFixed(0) < parentInnerWidth.toFixed(0); + }; + if (tagName === 'TH' || tagName === 'TD') { + if (parentTagName === 'TR') return 'horizontal'; + } + if (parentStyle.display === 'flex' && parentStyle.flexDirection === 'row') return 'horizontal'; + if (parentStyle.display === 'grid') { + if (isNotFullWidth()) { + return 'horizontal'; + } + } + if (InlineLayoutTagNames.has(tagName)) { + if (style.display === 'block') { + if (style.float === 'left' || style.float === 'right') { + if (isNotFullWidth()) { + return 'horizontal'; + } + } + return 'vertical'; + } + return 'horizontal'; + } + return ''; +};