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]];
+ }
+ };
+}