diff --git a/src/app/components/widgets/ghost/ghost.widget.ts b/src/app/components/widgets/ghost/ghost.widget.ts index 1a92258..c0c48de 100644 --- a/src/app/components/widgets/ghost/ghost.widget.ts +++ b/src/app/components/widgets/ghost/ghost.widget.ts @@ -1,9 +1,30 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Optional } from '@angular/core'; +import { usePrefix } from '../../../utils'; +import { NodeTitleWidget } from '../node-title/node-title.widget'; +import { Engine } from '../../../core/models'; +import { Cursor, CursorStatus } from '../../../core/models/cursor'; @Component({ selector: 'app-ghost', standalone: true, - template: ``, + template: ` + @if (cursor.status === CursorStatus.Dragging) { +
+ + + +
+ } + `, + imports: [NodeTitleWidget], changeDetection: ChangeDetectionStrategy.OnPush }) -export class GhostWidget {} +export class GhostWidget { + prefix = usePrefix('ghost'); + constructor( + @Optional() public designer: Engine, + @Optional() public cursor: Cursor + ) {} + + protected readonly CursorStatus = CursorStatus; +} diff --git a/src/app/components/widgets/node-title/node-title.widget.ts b/src/app/components/widgets/node-title/node-title.widget.ts new file mode 100644 index 0000000..df406e1 --- /dev/null +++ b/src/app/components/widgets/node-title/node-title.widget.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { TreeNode } from '../../../core/models'; + +@Component({ + selector: 'app-node-title-widget', + standalone: true, + template: ` {{ node.getMessage('title') || node.componentName }} `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NodeTitleWidget implements OnChanges { + @Input() node: TreeNode; + + currentNode: TreeNode; + + ngOnChanges(changes: SimpleChanges): void { + if (changes.node && changes.node.currentValue) { + this.currentNode = this.takeNode(this.node); + } + } + + private takeNode(node: TreeNode) { + if (node.componentName === '$$ResourceNode$$') { + return node.children[0]; + } + return node; + } +} diff --git a/src/app/core/models/cursor.ts b/src/app/core/models/cursor.ts index 0d3f928..66534dc 100644 --- a/src/app/core/models/cursor.ts +++ b/src/app/core/models/cursor.ts @@ -113,4 +113,54 @@ export class Cursor { constructor(engine: Engine) { this.engine = engine; } + + get speed() { + return Math.sqrt(Math.pow(this.dragAtomDelta.clientX, 2) + Math.pow(this.dragAtomDelta.clientY, 2)); + } + + setStatus(status: CursorStatus) { + this.status = status; + } + + setType(type: CursorType | string) { + this.type = type; + } + + setDragType(type: CursorDragType | string) { + this.dragType = type; + } + + setStyle(style: string) { + this.engine.workbench.eachWorkspace(workspace => { + setCursorStyle(workspace.viewport.contentWindow, style); + }); + } + + setPosition(position?: ICursorPosition) { + this.dragAtomDelta = calcPositionDelta(this.position, position); + this.position = { ...position }; + if (this.status === CursorStatus.Dragging) { + this.dragStartToCurrentDelta = calcPositionDelta(this.position, this.dragStartPosition); + } + } + + setDragStartPosition(position?: ICursorPosition) { + if (position) { + this.dragStartPosition = { ...position }; + } else { + this.dragStartPosition = null; + this.dragStartToCurrentDelta = DEFAULT_POSITION; + } + } + + setDragEndPosition(position?: ICursorPosition) { + if (!this.dragStartPosition) return; + if (position) { + this.dragEndPosition = { ...position }; + this.dragStartToEndDelta = calcPositionDelta(this.dragStartPosition, this.dragEndPosition); + } else { + this.dragEndPosition = null; + this.dragStartToEndDelta = DEFAULT_POSITION; + } + } } diff --git a/src/app/core/models/engine.ts b/src/app/core/models/engine.ts index 5fd85ab..ec462d2 100644 --- a/src/app/core/models/engine.ts +++ b/src/app/core/models/engine.ts @@ -3,6 +3,7 @@ import { IEngineProps } from '@/app/shared/types'; import { Cursor } from '@/app/core/models/cursor'; import { Screen } from '@/app/core/models/screen'; import { uid } from '@/app/shared/uid'; +import { TreeNode } from '@/app/core/models/tree-node'; export class Engine { id: string; @@ -28,4 +29,16 @@ export class Engine { this.cursor = new Cursor(this); // this.keyboard = new Keyboard(this) } + + findMovingNodes(): TreeNode[] { + const results = []; + this.workbench.eachWorkspace(workspace => { + workspace.operation.moveHelper.dragNodes?.forEach(node => { + if (!results.includes(node)) { + results.push(node); + } + }); + }); + return results; + } } diff --git a/src/app/core/models/workbench.ts b/src/app/core/models/workbench.ts index cc7dd71..3c4eacc 100644 --- a/src/app/core/models/workbench.ts +++ b/src/app/core/models/workbench.ts @@ -34,4 +34,8 @@ export class Workbench { // setWorkbenchType: action }); } + + eachWorkspace(callbackFn: (value: Workspace, index: number) => T) { + this.workspaces.forEach(callbackFn); + } } diff --git a/src/app/shared/event.ts b/src/app/shared/event.ts index 2716a1f..0e4b604 100644 --- a/src/app/shared/event.ts +++ b/src/app/shared/event.ts @@ -1,4 +1,12 @@ -import { ISubscriber } from '@/app/shared/subscription'; +import { ISubscriber, Subscribable } from '@/app/shared/subscription'; +import { isArr, isWindow } from '@/app/shared/types'; +import { globalThisPolyfill } from '@/app/shared/globalThisPolyfill'; + +const ATTACHED_SYMBOL = Symbol('ATTACHED_SYMBOL'); +const EVENTS_SYMBOL = Symbol('__EVENTS_SYMBOL__'); +const EVENTS_ONCE_SYMBOL = Symbol('EVENTS_ONCE_SYMBOL'); +const EVENTS_BATCH_SYMBOL = Symbol('EVENTS_BATCH_SYMBOL'); +const DRIVER_INSTANCES_SYMBOL = Symbol('DRIVER_INSTANCES_SYMBOL'); export interface ICustomEvent { type: string; @@ -6,6 +14,12 @@ export interface ICustomEvent { context?: EventContext; } +export interface CustomEventClass { + new (...args: any[]): any; +} + +const isOnlyMode = (mode: string) => mode === 'onlyOne' || mode === 'onlyChild' || mode === 'onlyParent'; + export type EventOptions = | boolean | (AddEventListenerOptions & @@ -13,6 +27,8 @@ export type EventOptions = mode?: 'onlyOne' | 'onlyParent' | 'onlyChild'; }); +export type EventContainer = Window | HTMLElement | HTMLDocument; + export type EventDriverContainer = HTMLElement | HTMLDocument; export interface IEventEffect { @@ -74,3 +90,255 @@ export interface IEventProps { drivers?: IEventDriverClass[]; effects?: IEventEffect[]; } + +/** + * 事件驱动器基类 + */ +export class EventDriver implements IEventDriver { + engine: Engine; + + container: EventDriverContainer = document; + + contentWindow: Window = globalThisPolyfill; + + context: Context; + + constructor(engine: Engine, context?: Context) { + this.engine = engine; + this.context = context; + } + + dispatch = any>(event: T) { + return this.engine.dispatch(event, this.context); + } + + subscribe = any>(subscriber: ISubscriber) { + return this.engine.subscribe(subscriber); + } + + subscribeTo(type: T, subscriber: ISubscriber>) { + return this.engine.subscribeTo(type, subscriber); + } + + subscribeWith(type: string | string[], subscriber: ISubscriber) { + return this.engine.subscribeWith(type, subscriber); + } + + attach(container: EventDriverContainer) { + console.error('attach must implement.'); + } + + detach(container: EventDriverContainer) { + console.error('attach must implement.'); + } + + eventTarget(type: string) { + if (type === 'resize' || type === 'scroll') { + if (this.container === this.contentWindow?.document) { + return this.contentWindow; + } + } + return this.container; + } + + addEventListener( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventOptions + ): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventOptions): void; + addEventListener(type: any, listener: any, options: any) { + const target = this.eventTarget(type); + if (isOnlyMode(options?.mode)) { + target[EVENTS_ONCE_SYMBOL] = target[EVENTS_ONCE_SYMBOL] || {}; + const constructor = this['constructor']; + constructor[EVENTS_ONCE_SYMBOL] = constructor[EVENTS_ONCE_SYMBOL] || {}; + const handler = target[EVENTS_ONCE_SYMBOL][type]; + const container = constructor[EVENTS_ONCE_SYMBOL][type]; + if (!handler) { + if (container) { + if (options.mode === 'onlyChild') { + if (container.contains(target)) { + container.removeEventListener(type, container[EVENTS_ONCE_SYMBOL][type], options); + delete container[EVENTS_ONCE_SYMBOL][type]; + } + } else if (options.mode === 'onlyParent') { + if (container.contains(target)) return; + } + } + target.addEventListener(type, listener, options); + target[EVENTS_ONCE_SYMBOL][type] = listener; + constructor[EVENTS_ONCE_SYMBOL][type] = target; + } + } else { + target[EVENTS_SYMBOL] = target[EVENTS_SYMBOL] || {}; + target[EVENTS_SYMBOL][type] = target[EVENTS_SYMBOL][type] || new Map(); + if (!target[EVENTS_SYMBOL][type]?.get?.(listener)) { + target.addEventListener(type, listener, options); + target[EVENTS_SYMBOL][type]?.set?.(listener, true); + } + } + } + + removeEventListener( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventOptions + ): void; + removeEventListener(type: any, listener: any, options?: any) { + const target = this.eventTarget(type); + if (isOnlyMode(options?.mode)) { + const constructor = this['constructor']; + constructor[EVENTS_ONCE_SYMBOL] = constructor[EVENTS_ONCE_SYMBOL] || {}; + target[EVENTS_ONCE_SYMBOL] = target[EVENTS_ONCE_SYMBOL] || {}; + delete constructor[EVENTS_ONCE_SYMBOL][type]; + delete target[EVENTS_ONCE_SYMBOL][type]; + target.removeEventListener(type, listener, options); + } else { + target[EVENTS_SYMBOL] = target[EVENTS_SYMBOL] || {}; + target[EVENTS_SYMBOL][type] = target[EVENTS_SYMBOL][type] || new Map(); + target[EVENTS_SYMBOL][type]?.delete?.(listener); + target.removeEventListener(type, listener, options); + } + } + + batchAddEventListener( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventOptions + ): void; + batchAddEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventOptions + ): void; + batchAddEventListener(type: any, listener: any, options?: any) { + this.engine[DRIVER_INSTANCES_SYMBOL] = this.engine[DRIVER_INSTANCES_SYMBOL] || []; + if (!this.engine[DRIVER_INSTANCES_SYMBOL].includes(this)) { + this.engine[DRIVER_INSTANCES_SYMBOL].push(this); + } + this.engine[DRIVER_INSTANCES_SYMBOL].forEach(driver => { + const target = driver.eventTarget(type); + target[EVENTS_BATCH_SYMBOL] = target[EVENTS_BATCH_SYMBOL] || {}; + if (!target[EVENTS_BATCH_SYMBOL][type]) { + target.addEventListener(type, listener, options); + target[EVENTS_BATCH_SYMBOL][type] = listener; + } + }); + } + + batchRemoveEventListener( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventOptions + ): void; + batchRemoveEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventOptions + ): void; + batchRemoveEventListener(type: any, listener: any, options: any) { + this.engine[DRIVER_INSTANCES_SYMBOL] = this.engine[DRIVER_INSTANCES_SYMBOL] || []; + this.engine[DRIVER_INSTANCES_SYMBOL].forEach(driver => { + const target = driver.eventTarget(type); + target[EVENTS_BATCH_SYMBOL] = target[EVENTS_BATCH_SYMBOL] || {}; + target.removeEventListener(type, listener, options); + delete target[EVENTS_BATCH_SYMBOL][type]; + }); + } +} + +/** + * 事件引擎 + */ +export class Event extends Subscribable> { + private drivers: IEventDriverClass[] = []; + private containers: EventContainer[] = []; + + constructor(props?: IEventProps) { + super(); + if (isArr(props?.effects)) { + props.effects.forEach(plugin => { + plugin(this); + }); + } + if (isArr(props?.drivers)) { + this.drivers = props.drivers; + } + } + + subscribeTo(type: T, subscriber: ISubscriber>) { + return this.subscribe(event => { + if (type && event instanceof type) { + return subscriber(event); + } + }); + } + + subscribeWith(type: string | string[], subscriber: ISubscriber) { + return this.subscribe(event => { + if (isArr(type)) { + if (type.includes(event?.type)) { + return subscriber(event); + } + } else { + if (type && event?.type === type) { + return subscriber(event); + } + } + }); + } + + attachEvents(container: EventContainer, contentWindow: Window = globalThisPolyfill, context?: any) { + if (!container) return; + if (isWindow(container)) { + return this.attachEvents(container.document, container, context); + } + if (container[ATTACHED_SYMBOL]) return; + container[ATTACHED_SYMBOL] = this.drivers.map(EventDriver => { + const driver = new EventDriver(this, context); + driver.contentWindow = contentWindow; + driver.container = container; + driver.attach(container); + return driver; + }); + if (!this.containers.includes(container)) { + this.containers.push(container); + } + } + + detachEvents(container?: EventContainer) { + if (!container) { + this.containers.forEach(container => { + this.detachEvents(container); + }); + return; + } + if (isWindow(container)) { + return this.detachEvents(container.document); + } + if (!container[ATTACHED_SYMBOL]) return; + container[ATTACHED_SYMBOL].forEach(driver => { + driver.detach(container); + }); + + this[DRIVER_INSTANCES_SYMBOL] = this[DRIVER_INSTANCES_SYMBOL] || []; + this[DRIVER_INSTANCES_SYMBOL] = this[DRIVER_INSTANCES_SYMBOL].reduce((drivers, driver) => { + if (driver.container === container) { + driver.detach(container); + return drivers; + } + return drivers.concat(driver); + }, []); + this.containers = this.containers.filter(item => item !== container); + delete container[ATTACHED_SYMBOL]; + delete container[EVENTS_SYMBOL]; + delete container[EVENTS_ONCE_SYMBOL]; + delete container[EVENTS_BATCH_SYMBOL]; + } +} diff --git a/src/app/shared/subscription.ts b/src/app/shared/subscription.ts index f2b1b19..fe7b0ad 100644 --- a/src/app/shared/subscription.ts +++ b/src/app/shared/subscription.ts @@ -1,3 +1,59 @@ +import { isFn } from '@/app/shared/types'; +const UNSUBSCRIBE_ID_SYMBOL = Symbol('UNSUBSCRIBE_ID_SYMBOL'); + export interface ISubscriber { (payload: Payload): void | boolean; } + +export class Subscribable { + private subscribers: { + index?: number; + [key: number]: ISubscriber; + } = { + index: 0 + }; + + dispatch(event: T, context?: any) { + let interrupted = false; + for (const key in this.subscribers) { + if (isFn(this.subscribers[key])) { + event['context'] = context; + if (this.subscribers[key](event) === false) { + interrupted = true; + } + } + } + return interrupted ? false : true; + } + + subscribe(subscriber: ISubscriber) { + let id: number; + if (isFn(subscriber)) { + id = this.subscribers.index + 1; + this.subscribers[id] = subscriber; + this.subscribers.index++; + } + + const unsubscribe = () => { + this.unsubscribe(id); + }; + + unsubscribe[UNSUBSCRIBE_ID_SYMBOL] = id; + + return unsubscribe; + } + + unsubscribe = (id?: number | string | (() => void)) => { + if (id === undefined || id === null) { + for (const key in this.subscribers) { + this.unsubscribe(key); + } + return; + } + if (!isFn(id)) { + delete this.subscribers[id]; + } else { + delete this.subscribers[id[UNSUBSCRIBE_ID_SYMBOL]]; + } + }; +}