From e08496cd1e4d7930ba6466e72fda6f918961e9c7 Mon Sep 17 00:00:00 2001 From: "Z.X" Date: Wed, 21 Jan 2026 12:03:13 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E7=82=B9=E7=B1=BB=E5=9E=8B=E4=B8=8E=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/magic-drag/index.ts | 1 + src/magic-drag/types.ts | 53 +++++++++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/magic-drag/index.ts b/src/magic-drag/index.ts index 12a03aa..5303ad1 100644 --- a/src/magic-drag/index.ts +++ b/src/magic-drag/index.ts @@ -9,6 +9,7 @@ export { type MagicDragEventMap, type MagicDragManagerOptions, type MagicDragMessage, + type MagicDragCoordinator, type MagicDragMessagePayload, type MagicDragOptions, type PreviewInfo, diff --git a/src/magic-drag/types.ts b/src/magic-drag/types.ts index 77c81a2..2b42284 100644 --- a/src/magic-drag/types.ts +++ b/src/magic-drag/types.ts @@ -111,6 +111,7 @@ export interface MagicDragOptions { previewZIndex?: number /** 预览元素的透明度,默认 0.7 */ previewOpacity?: number + coordinator?: MagicDragCoordinator } /** @@ -172,22 +173,54 @@ export interface PreviewInfo { export type MagicDragConstructor = (new (element: HTMLElement, ...args: never[]) => T) & { channelName?: string + onEnterTab?: (payload: MagicDragMessagePayload) => void } -/** - * MagicDrag 基类接口 - * 用于类型约束,不直接使用 - */ -export interface MagicDragBase { - /** 实例唯一标识 */ +export interface MagicDragOtherTabExtensions { + onOtherTabDragStart?: (payload: MagicDragMessagePayload) => void + onOtherTabDragMove?: (payload: MagicDragMessagePayload) => void + onOtherTabDragEnd?: (payload: MagicDragMessagePayload) => void +} + +export type MagicDragOtherTabExtensionName = keyof MagicDragOtherTabExtensions + +export interface MagicDragCoordinator { + readonly tabId: string + registerInstance(instance: MagicDragBase): void + unregisterInstance(instanceId: string): void + notifyDragStart(instanceId: string, serializedData: SerializedData): void + notifyDragMove( + instanceId: string, + screenPosition: ScreenPosition, + serializedData: SerializedData + ): void + notifyDragEnd(instanceId: string, serializedData: SerializedData): void + notifyDragDrop( + instanceId: string, + serializedData: SerializedData, + targetTabId: string + ): void + notifyDragAbort(instanceId: string, serializedData: SerializedData): void + getDragState(): Readonly +} + +let magicDragCoordinator: MagicDragCoordinator | null = null + +export const setMagicDragCoordinator = ( + coordinator: MagicDragCoordinator | null +): void => { + magicDragCoordinator = coordinator +} + +export const getMagicDragCoordinator = (): MagicDragCoordinator | null => + magicDragCoordinator + +export interface MagicDragBase extends MagicDragOtherTabExtensions { readonly instanceId: string - /** 关联的 HTML 元素 */ readonly element: HTMLElement - /** 序列化实例状态 */ + getClassName(): string serialize(): SerializedData - /** 从序列化数据恢复实例状态 */ deserialize(data: SerializedData): void - /** 销毁实例 */ destroy(): void } From 2cd63a6548cfee97edb15bbdad435e421ef0297b Mon Sep 17 00:00:00 2001 From: "Z.X" Date: Wed, 21 Jan 2026 12:03:25 +0000 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=E8=A7=A3=E8=80=A6=20MagicDrag?= =?UTF-8?q?=20=E5=B9=B6=E5=AE=8C=E5=96=84=E5=88=86=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/magic-drag/MagicDrag.ts | 51 +++++--- src/magic-drag/MagicDragManager.ts | 185 +++++++++++++++++++++++++++-- 2 files changed, 207 insertions(+), 29 deletions(-) diff --git a/src/magic-drag/MagicDrag.ts b/src/magic-drag/MagicDrag.ts index 55e52ee..0524e02 100644 --- a/src/magic-drag/MagicDrag.ts +++ b/src/magic-drag/MagicDrag.ts @@ -4,13 +4,14 @@ import { defaultGetPose, type Pose } from '@system-ui-js/multi-drag' -import { MagicDragManager } from './MagicDragManager' import type { DragOffset, MagicDragOptions, + MagicDragCoordinator, ScreenPosition, SerializedData } from './types' +import { getMagicDragCoordinator } from './types' /** * 生成 UUID @@ -50,15 +51,24 @@ function generateUUID(): string { } export abstract class MagicDrag { - static readonly channelName = 'magic-drag-channel' + static channelName: string readonly instanceId: string = generateUUID() readonly element: HTMLElement protected drag: Drag - protected manager: MagicDragManager + protected coordinator: MagicDragCoordinator protected options: MagicDragOptions + protected getDefaultCoordinator( + _channelName: string, + _previewContainer?: HTMLElement + ): MagicDragCoordinator { + throw new Error( + 'MagicDragCoordinator is required. Please register the class with MagicDragManager before creating instances.' + ) + } + private isDragging = false private hasLeftTab = false private dragOffset: DragOffset = { x: 0, y: 0 } @@ -70,24 +80,26 @@ export abstract class MagicDrag { const constructor = this.constructor as typeof MagicDrag const channelName = constructor.channelName - this.manager = MagicDragManager.getInstance({ - channelName, - previewContainer: options.previewContainer - }) + const coordinator = + options.coordinator ?? + getMagicDragCoordinator() ?? + this.getDefaultCoordinator(channelName, options.previewContainer) + + this.coordinator = coordinator this.drag = new Drag(element, { inertial: options.inertial ?? false }) this.setupDragEvents() - this.manager.registerInstance(this) + this.coordinator.registerInstance(this) } abstract serialize(): SerializedData abstract deserialize(data: SerializedData): void - protected abstract getClassName(): string + abstract getClassName(): string protected getChannelName(): string { const constructor = this.constructor as typeof MagicDrag @@ -151,7 +163,7 @@ export abstract class MagicDrag { } const serializedData = this.serialize() - this.manager.notifyDragStart(this.instanceId, serializedData) + this.coordinator.notifyDragStart(this.instanceId, serializedData) const screenPosition = this.lastScreenPosition ?? this.getScreenPosition(fingers) @@ -168,7 +180,11 @@ export abstract class MagicDrag { const serializedData = this.serialize() this.lastScreenPosition = screenPosition - this.manager.notifyDragMove(this.instanceId, screenPosition, serializedData) + this.coordinator.notifyDragMove( + this.instanceId, + screenPosition, + serializedData + ) if (isLeaveTab && !this.hasLeftTab) { this.hasLeftTab = true @@ -187,16 +203,15 @@ export abstract class MagicDrag { } const serializedData = this.serialize() - const dragState = this.manager.getDragState() - console.log('[handleDragEnd] dragState: ', dragState) + const dragState = this.coordinator.getDragState() const screenPosition = this.lastScreenPosition ?? this.getScreenPosition() if ( this.hasLeftTab && dragState.activeTabId && - dragState.activeTabId !== this.manager.tabId + dragState.activeTabId !== this.coordinator.tabId ) { - this.manager.notifyDragDrop( + this.coordinator.notifyDragDrop( this.instanceId, serializedData, dragState.activeTabId @@ -208,7 +223,7 @@ export abstract class MagicDrag { } if (this.hasLeftTab && !dragState.activeTabId) { - this.manager.notifyDragAbort(this.instanceId, serializedData) + this.coordinator.notifyDragAbort(this.instanceId, serializedData) this.isDragging = false this.hasLeftTab = false this.onAbort(screenPosition) @@ -216,7 +231,7 @@ export abstract class MagicDrag { } const isLeaveTab = this.hasLeftTab - this.manager.notifyDragEnd(this.instanceId, serializedData) + this.coordinator.notifyDragEnd(this.instanceId, serializedData) this.isDragging = false this.hasLeftTab = false @@ -287,7 +302,7 @@ export abstract class MagicDrag { destroy(): void { this.drag.setDisabled() - this.manager.unregisterInstance(this.instanceId) + this.coordinator.unregisterInstance(this.instanceId) this.element.remove() } } diff --git a/src/magic-drag/MagicDragManager.ts b/src/magic-drag/MagicDragManager.ts index c65aaa1..2a4074c 100644 --- a/src/magic-drag/MagicDragManager.ts +++ b/src/magic-drag/MagicDragManager.ts @@ -10,11 +10,14 @@ import type { MagicDragManagerOptions, MagicDragMessage, MagicDragEventMap, + MagicDragMessagePayload, + MagicDragOtherTabExtensionName, PreviewInfo, ScreenPosition, SerializedData, TabInfo } from './types' +import { setMagicDragCoordinator } from './types' import { MagicDragMessageType } from './types' const DEFAULT_CHANNEL_NAME = 'magic-drag-channel' @@ -48,10 +51,13 @@ export class MagicDragManager { string, (event: MessageEvent) => void >() + private unavailableChannels = new Set() private options: Required private classRegistry = new Map() private instances = new Map() + private instancesByClassName = new Map>() + private classNameByChannel = new Map() private knownTabs = new Map() private eventListeners: MagicDragEventListenerStore = new Map() private channelNames = new Map() @@ -84,6 +90,7 @@ export class MagicDragManager { this.setupChannelListener(this.options.channelName) this.setupTabActivationListener() this.startHeartbeat() + setMagicDragCoordinator(this) } static getInstance(options?: MagicDragManagerOptions): MagicDragManager { @@ -98,10 +105,15 @@ export class MagicDragManager { MagicDragManager.instance.destroy() MagicDragManager.instance = null } + setMagicDragCoordinator(null) } registerClass(className: string, constructor: MagicDragConstructor): void { - this.classRegistry.set(className, constructor) + if (this.classRegistry.has(className)) { + throw new Error( + `[MagicDragManager] Duplicate className registration: ${className}` + ) + } const channelName = constructor.channelName ?? DEFAULT_CHANNEL_NAME if (!channelName || channelName.trim().length === 0) { @@ -110,7 +122,17 @@ export class MagicDragManager { ) } + const existingClass = this.classNameByChannel.get(channelName) + if (existingClass && existingClass !== className) { + throw new Error( + `[MagicDragManager] channelName conflict: ${channelName} already used by ${existingClass}` + ) + } + + this.classRegistry.set(className, constructor) this.channelNames.set(className, channelName) + this.classNameByChannel.set(channelName, className) + const nextCount = (this.channelRefCounts.get(channelName) ?? 0) + 1 this.channelRefCounts.set(channelName, nextCount) this.setupChannelListener(channelName) @@ -122,6 +144,10 @@ export class MagicDragManager { this.classRegistry.delete(className) this.channelNames.delete(className) + if (channelName) { + this.classNameByChannel.delete(channelName) + } + if (!channelName) { return } @@ -183,9 +209,27 @@ export class MagicDragManager { registerInstance(instance: MagicDragBase): void { this.instances.set(instance.instanceId, instance) + const className = instance.getClassName() + const collection = this.instancesByClassName.get(className) ?? new Set() + collection.add(instance) + this.instancesByClassName.set(className, collection) } unregisterInstance(instanceId: string): void { + const instance = this.instances.get(instanceId) + if (!instance) { + return + } + + const className = instance.getClassName() + const collection = this.instancesByClassName.get(className) + if (collection) { + collection.delete(instance) + if (collection.size === 0) { + this.instancesByClassName.delete(className) + } + } + this.instances.delete(instanceId) } @@ -213,8 +257,16 @@ export class MagicDragManager { sourceTabId: this.tabId } const channelName = this.getMessageChannelName(fullMessage) - this.setupChannelListener(channelName) - this.getChannel(channelName).postMessage(fullMessage) + + try { + this.setupChannelListener(channelName) + this.getChannel(channelName).postMessage(fullMessage) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[MagicDragManager] Failed to broadcast ${fullMessage.type} on ${channelName}: ${reason}` + ) + } } notifyDragStart(instanceId: string, serializedData: SerializedData): void { @@ -324,8 +376,23 @@ export class MagicDragManager { private getChannel(channelName: string): BroadcastChannel { let channel = this.channels.get(channelName) if (!channel) { - channel = new BroadcastChannel(channelName) - this.channels.set(channelName, channel) + if (this.unavailableChannels.has(channelName)) { + throw new Error( + `[MagicDragManager] BroadcastChannel unavailable for ${channelName}` + ) + } + + try { + channel = new BroadcastChannel(channelName) + this.channels.set(channelName, channel) + } catch (error) { + this.unavailableChannels.add(channelName) + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[MagicDragManager] Failed to create BroadcastChannel for ${channelName}: ${reason}` + ) + throw error + } } return channel } @@ -335,7 +402,17 @@ export class MagicDragManager { return } - const channel = this.getChannel(channelName) + let channel: BroadcastChannel + try { + channel = this.getChannel(channelName) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[MagicDragManager] BroadcastChannel disabled; fallback to single-tab. channelName=${channelName} reason=${reason}` + ) + return + } + const handler = (event: MessageEvent) => { const message = event.data @@ -369,6 +446,18 @@ export class MagicDragManager { } private handleExternalMessage(message: MagicDragMessage): void { + const className = message.payload.serializedData?.className + if (className && !this.classRegistry.has(className)) { + const channelName = this.channelNames.get(className) + this.warnUnregisteredClassName( + className, + 'Unregistered class message ignored', + message.type, + channelName + ) + return + } + this.updateTabInfo(message.sourceTabId) this.dispatchEvent(message) @@ -418,7 +507,7 @@ export class MagicDragManager { private dispatchEvent(message: MagicDragMessage): void { const listeners = this.eventListeners.get(message.type) if (!listeners || listeners.size === 0) { - this.maybeLogNoListener(message.type) + this.maybeLogNoListener(message) return } @@ -455,15 +544,20 @@ export class MagicDragManager { return this.resolveMessageChannelName(message) ?? this.options.channelName } - private maybeLogNoListener(type: MagicDragMessageType): void { + private maybeLogNoListener(message: MagicDragMessage): void { const now = Date.now() - const lastLogAt = this.lastNoListenerLogAt.get(type) ?? 0 + const lastLogAt = this.lastNoListenerLogAt.get(message.type) ?? 0 if (now - lastLogAt < DEFAULT_NO_LISTENER_LOG_INTERVAL) { return } - this.lastNoListenerLogAt.set(type, now) - console.warn(`[MagicDragManager] No listeners registered for ${type}`) + this.lastNoListenerLogAt.set(message.type, now) + + const className = message.payload.serializedData?.className + const channelName = className ? this.channelNames.get(className) : undefined + console.warn( + `[MagicDragManager] No listeners registered for ${message.type} class=${className ?? 'unknown'} channel=${channelName ?? 'unknown'}` + ) } private handleExternalDragStart(message: MagicDragMessage): void { @@ -477,6 +571,11 @@ export class MagicDragManager { } this.pendingExternalDrag = message + this.invokeOtherTabCallbacks( + message.payload.serializedData?.className ?? null, + 'onOtherTabDragStart', + message.payload + ) } private handleExternalDragMove(message: MagicDragMessage): void { @@ -517,10 +616,21 @@ export class MagicDragManager { this.dragState.serializedData?.dragOffset ) } + + this.invokeOtherTabCallbacks( + message.payload.serializedData?.className ?? null, + 'onOtherTabDragMove', + message.payload + ) } private handleExternalDragEnd(message: MagicDragMessage): void { if (this.dragState.sourceTabId === message.sourceTabId) { + this.invokeOtherTabCallbacks( + message.payload.serializedData?.className ?? null, + 'onOtherTabDragEnd', + message.payload + ) this.resetDragState() this.removePreview() } @@ -535,6 +645,17 @@ export class MagicDragManager { private handleExternalDragEnterTab(message: MagicDragMessage): void { if (message.sourceTabId === this.dragState.sourceTabId) { + const serializedData = message.payload.serializedData + const className = serializedData?.className ?? null + const Constructor = className ? this.classRegistry.get(className) : null + if (!Constructor && className) { + console.warn(`[MagicDragManager] Unknown class: ${className}`) + } + + if (Constructor?.onEnterTab && serializedData) { + Constructor.onEnterTab(message.payload) + } + this.dragState.activeTabId = message.targetTabId ?? this.tabId if ( !this.previewInfo && @@ -551,6 +672,48 @@ export class MagicDragManager { private handleExternalDragLeaveTab(_message: MagicDragMessage): void {} + private warnUnregisteredClassName( + className: string, + context: string, + messageType?: MagicDragMessageType, + channelName?: string + ): void { + const typeLabel = messageType ? ` type=${messageType}` : '' + const channelLabel = channelName ? ` channel=${channelName}` : '' + console.warn( + `[MagicDragManager] ${context}: ${className}${typeLabel}${channelLabel}` + ) + } + + private invokeOtherTabCallbacks( + className: string | null, + method: MagicDragOtherTabExtensionName, + payload: MagicDragMessagePayload + ): void { + if (!className) { + return + } + + const instances = this.instancesByClassName.get(className) + if (!instances || instances.size === 0) { + const channelName = this.channelNames.get(className) + this.warnUnregisteredClassName( + className, + 'Unknown class', + undefined, + channelName + ) + return + } + + for (const instance of instances) { + const callback = instance[method] + if (typeof callback === 'function') { + callback.call(instance, payload) + } + } + } + private isMouseInCurrentTab(screenPosition: ScreenPosition): boolean { const clientX = screenPosition.screenX - window.screenX const clientY = screenPosition.screenY - window.screenY From 88ef7244b3ca57efd288806cfa31467bd9a3e6a3 Mon Sep 17 00:00:00 2001 From: "Z.X" Date: Wed, 21 Jan 2026 12:03:38 +0000 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=E8=B7=A8=20Tab?= =?UTF-8?q?=20=E5=9B=9E=E8=B0=83=E4=B8=8E=E6=89=A9=E5=B1=95=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/__tests__/magic-drag.test.ts | 193 ++++++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 1 deletion(-) diff --git a/src/__tests__/magic-drag.test.ts b/src/__tests__/magic-drag.test.ts index 11c3271..1e8c852 100644 --- a/src/__tests__/magic-drag.test.ts +++ b/src/__tests__/magic-drag.test.ts @@ -78,6 +78,8 @@ interface TestCardData { } class TestCard extends MagicDrag { + static onEnterTab = jest.fn() + private title: string private content: string @@ -87,10 +89,14 @@ class TestCard extends MagicDrag { this.content = content } - protected getClassName(): string { + getClassName(): string { return 'TestCard' } + onOtherTabDragStart = jest.fn() + onOtherTabDragMove = jest.fn() + onOtherTabDragEnd = jest.fn() + serialize(): SerializedData { return this.createSerializedData({ title: this.title, @@ -116,6 +122,7 @@ describe('MagicDragManager', () => { beforeEach(() => { MockBroadcastChannel.reset() MagicDragManager.resetInstance() + TestCard.onEnterTab.mockClear() }) it('should create singleton instance', () => { @@ -159,12 +166,85 @@ describe('MagicDragManager', () => { card.destroy() }) + + it('should call onEnterTab for registered class', () => { + const manager = MagicDragManager.getInstance() + manager.registerClass('TestCard', TestCard) + + const element = document.createElement('div') + const card = new TestCard(element) + + const channel = new BroadcastChannel('magic-drag-channel') + const serializedData = card.serialize() + + channel.postMessage({ + type: 'magic_drag_start', + instanceId: card.instanceId, + sourceTabId: 'external-tab', + payload: { + serializedData, + timestamp: Date.now() + } + }) + + channel.postMessage({ + type: 'magic_drag_enter_tab', + instanceId: card.instanceId, + sourceTabId: 'external-tab', + targetTabId: manager.tabId, + payload: { + serializedData, + timestamp: Date.now() + } + }) + + expect(TestCard.onEnterTab).toHaveBeenCalledTimes(1) + + channel.close() + card.destroy() + }) + + it('should warn and ignore unregistered class messages', () => { + const manager = MagicDragManager.getInstance() + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + const channel = new BroadcastChannel('magic-drag-channel') + const message = { + type: 'magic_drag_enter_tab', + instanceId: 'unknown-instance', + sourceTabId: 'external-tab', + targetTabId: manager.tabId, + payload: { + serializedData: { + instanceId: 'unknown-instance', + className: 'UnknownCard', + pose: { + position: { x: 0, y: 0 }, + width: 0, + height: 0 + }, + customData: null + }, + timestamp: Date.now() + } + } + + channel.postMessage(message) + + expect(warnSpy).toHaveBeenCalled() + expect(TestCard.onEnterTab).not.toHaveBeenCalled() + + warnSpy.mockRestore() + channel.close() + }) }) describe('MagicDrag', () => { beforeEach(() => { MockBroadcastChannel.reset() MagicDragManager.resetInstance() + const manager = MagicDragManager.getInstance() + manager.registerClass('TestCard', TestCard) }) it('should create instance with unique id', () => { @@ -228,6 +308,79 @@ describe('MagicDrag', () => { expect(manager.getInstance(card.instanceId)).toBeUndefined() }) + it('should receive other tab drag callbacks', () => { + const element = document.createElement('div') + const card = new TestCard(element) + + const channel = new BroadcastChannel('magic-drag-channel') + const serializedData = card.serialize() + + channel.postMessage({ + type: 'magic_drag_start', + instanceId: 'external-instance', + sourceTabId: 'external-tab', + payload: { + serializedData, + timestamp: Date.now() + } + }) + + channel.postMessage({ + type: 'magic_drag_move', + instanceId: 'external-instance', + sourceTabId: 'external-tab', + payload: { + serializedData, + screenPosition: { screenX: 100, screenY: 120 }, + timestamp: Date.now() + } + }) + + channel.postMessage({ + type: 'magic_drag_end', + instanceId: 'external-instance', + sourceTabId: 'external-tab', + payload: { + serializedData, + timestamp: Date.now() + } + }) + + expect(card.onOtherTabDragStart).toHaveBeenCalledTimes(1) + expect(card.onOtherTabDragMove).toHaveBeenCalledTimes(1) + expect(card.onOtherTabDragEnd).toHaveBeenCalledTimes(1) + + channel.close() + card.destroy() + }) + + it('should broadcast callbacks to all same-class instances', () => { + const elementA = document.createElement('div') + const elementB = document.createElement('div') + const cardA = new TestCard(elementA) + const cardB = new TestCard(elementB) + + const channel = new BroadcastChannel('magic-drag-channel') + const serializedData = cardA.serialize() + + channel.postMessage({ + type: 'magic_drag_start', + instanceId: 'external-instance', + sourceTabId: 'external-tab', + payload: { + serializedData, + timestamp: Date.now() + } + }) + + expect(cardA.onOtherTabDragStart).toHaveBeenCalledTimes(1) + expect(cardB.onOtherTabDragStart).toHaveBeenCalledTimes(1) + + channel.close() + cardA.destroy() + cardB.destroy() + }) + it('should unregister instance on destroy', () => { const manager = MagicDragManager.getInstance() const element = document.createElement('div') @@ -260,4 +413,42 @@ describe('MagicDrag', () => { expect(abortCalled).toBe(true) card.destroy() }) + + it('should allow missing optional extensions without errors', () => { + class MinimalCard extends MagicDrag { + static channelName = 'minimal-card-channel' + + getClassName(): string { + return 'MinimalCard' + } + + serialize(): SerializedData { + return this.createSerializedData({ title: 'a', content: 'b' }) + } + + deserialize(_data: SerializedData): void {} + } + + const manager = MagicDragManager.getInstance() + manager.registerClass('MinimalCard', MinimalCard) + + const element = document.createElement('div') + const card = new MinimalCard(element) + + const channel = new BroadcastChannel('minimal-card-channel') + channel.postMessage({ + type: 'magic_drag_start', + instanceId: 'external-instance', + sourceTabId: 'external-tab', + payload: { + serializedData: card.serialize(), + timestamp: Date.now() + } + }) + + expect(card.instanceId).toBeDefined() + + channel.close() + card.destroy() + }) }) From aa73b13e973f8d4420965e73bbd4d5644ce4dfbc Mon Sep 17 00:00:00 2001 From: "Z.X" Date: Wed, 21 Jan 2026 12:03:54 +0000 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=E6=96=87=E6=A1=A3=E4=B8=8E=E5=8F=98=E6=9B=B4=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- CHANGELOG.md | 9 +- .../contracts/magic-drag-api.yaml | 303 ++++++++++++++++++ specs/002-magicdrag-refactor/data-model.md | 30 ++ specs/002-magicdrag-refactor/plan.md | 70 ++++ specs/002-magicdrag-refactor/quickstart.md | 51 +++ specs/002-magicdrag-refactor/research.md | 21 ++ specs/002-magicdrag-refactor/spec.md | 111 +++++++ specs/002-magicdrag-refactor/tasks.md | 166 ++++++++++ 8 files changed, 760 insertions(+), 1 deletion(-) create mode 100644 specs/002-magicdrag-refactor/contracts/magic-drag-api.yaml create mode 100644 specs/002-magicdrag-refactor/data-model.md create mode 100644 specs/002-magicdrag-refactor/plan.md create mode 100644 specs/002-magicdrag-refactor/quickstart.md create mode 100644 specs/002-magicdrag-refactor/research.md create mode 100644 specs/002-magicdrag-refactor/spec.md create mode 100644 specs/002-magicdrag-refactor/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f41d0..4d00b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning. -## Unreleased +## [UnReleased] ### Added +- MagicDrag 扩展点支持静态 onEnterTab 与实例 onOtherTabDrag* 回调 +- 引入 coordinator 以解耦 MagicDrag 与 Manager 之间的直接依赖 +- 新增跨 Tab 回调与同类实例广播测试覆盖 - 支持按通道名注册事件监听器,并提供全局事件分发接口 - MagicDrag 子类可通过静态 channelName 指定跨标签页通信通道 ### Changed +- Manager 按 className/channelName 管理注册表并拒绝通道冲突 +- BroadcastChannel 不可用时降级为单 Tab 行为并记录诊断日志 +- 未注册 className 的跨 Tab 消息直接忽略并记录 warning - Manager 统一分发拖拽信号并在无监听器时记录可观测日志 - 跨标签页拖拽 activeTabId 在拖拽开始与进入目标标签页时更新 ### Fixed +- 测试覆盖完整的跨 Tab 进入与回调流程 - 移除无用的测试文件 `src/__tests__/sum.test.ts` - 修复 tsconfig.build.json 配置冲突:将 `noEmit: false` 改为 `emitDeclarationOnly: true`,解决 `allowImportingTsExtensions` 只能在 `noEmit` 或 `emitDeclarationOnly` 时使用的 TypeScript 编译错误 - 修复 ESLint 错误:将 `MagicDrag.channelName` 改为 `readonly`,符合 sonarjs/public-static-readonly 规则 diff --git a/specs/002-magicdrag-refactor/contracts/magic-drag-api.yaml b/specs/002-magicdrag-refactor/contracts/magic-drag-api.yaml new file mode 100644 index 0000000..693b1ea --- /dev/null +++ b/specs/002-magicdrag-refactor/contracts/magic-drag-api.yaml @@ -0,0 +1,303 @@ +openapi: 3.0.3 +info: + title: MagicDrag Cross-Tab Events + version: "0.1" +paths: + /events/drag-start: + post: + summary: Broadcast drag start event to other tabs + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DragStartMessage" + responses: + "202": + description: Accepted + /events/drag-move: + post: + summary: Broadcast drag move event to other tabs + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DragMoveMessage" + responses: + "202": + description: Accepted + /events/drag-end: + post: + summary: Broadcast drag end event to other tabs + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DragEndMessage" + responses: + "202": + description: Accepted + /events/drag-enter-tab: + post: + summary: Broadcast drag enter event to other tabs + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DragEnterTabMessage" + responses: + "202": + description: Accepted + /events/drag-leave-tab: + post: + summary: Broadcast drag leave event to other tabs + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DragLeaveTabMessage" + responses: + "202": + description: Accepted + /events/drag-drop: + post: + summary: Broadcast drag drop event to other tabs + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DragDropMessage" + responses: + "202": + description: Accepted + /events/drag-abort: + post: + summary: Broadcast drag abort event to other tabs + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DragAbortMessage" + responses: + "202": + description: Accepted + /events/heartbeat: + post: + summary: Broadcast heartbeat event to other tabs + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/HeartbeatMessage" + responses: + "202": + description: Accepted +components: + schemas: + ScreenPosition: + type: object + required: [screenX, screenY] + properties: + screenX: + type: number + screenY: + type: number + DragOffset: + type: object + required: [x, y] + properties: + x: + type: number + y: + type: number + Pose: + type: object + required: [x, y, width, height, rotate, scaleX, scaleY] + properties: + x: { type: number } + y: { type: number } + width: { type: number } + height: { type: number } + rotate: { type: number } + scaleX: { type: number } + scaleY: { type: number } + SerializedData: + type: object + required: [instanceId, className, pose, customData, dragOffset] + properties: + instanceId: + type: string + className: + type: string + pose: + $ref: "#/components/schemas/Pose" + customData: + type: object + additionalProperties: true + dragOffset: + $ref: "#/components/schemas/DragOffset" + DragStartMessage: + type: object + required: [type, instanceId, sourceTabId, payload] + properties: + type: + type: string + enum: [DRAG_START] + instanceId: + type: string + sourceTabId: + type: string + payload: + type: object + required: [serializedData, timestamp] + properties: + serializedData: + $ref: "#/components/schemas/SerializedData" + timestamp: + type: number + DragMoveMessage: + type: object + required: [type, instanceId, sourceTabId, payload] + properties: + type: + type: string + enum: [DRAG_MOVE] + instanceId: + type: string + sourceTabId: + type: string + payload: + type: object + required: [serializedData, screenPosition, timestamp] + properties: + serializedData: + $ref: "#/components/schemas/SerializedData" + screenPosition: + $ref: "#/components/schemas/ScreenPosition" + timestamp: + type: number + DragEndMessage: + type: object + required: [type, instanceId, sourceTabId, payload] + properties: + type: + type: string + enum: [DRAG_END] + instanceId: + type: string + sourceTabId: + type: string + payload: + type: object + required: [serializedData, timestamp] + properties: + serializedData: + $ref: "#/components/schemas/SerializedData" + timestamp: + type: number + DragEnterTabMessage: + type: object + required: [type, instanceId, sourceTabId, targetTabId, payload] + properties: + type: + type: string + enum: [DRAG_ENTER_TAB] + instanceId: + type: string + sourceTabId: + type: string + targetTabId: + type: string + payload: + type: object + required: [serializedData, timestamp] + properties: + serializedData: + $ref: "#/components/schemas/SerializedData" + timestamp: + type: number + DragLeaveTabMessage: + type: object + required: [type, instanceId, sourceTabId, targetTabId, payload] + properties: + type: + type: string + enum: [DRAG_LEAVE_TAB] + instanceId: + type: string + sourceTabId: + type: string + targetTabId: + type: string + payload: + type: object + required: [serializedData, timestamp] + properties: + serializedData: + $ref: "#/components/schemas/SerializedData" + timestamp: + type: number + DragDropMessage: + type: object + required: [type, instanceId, sourceTabId, targetTabId, payload] + properties: + type: + type: string + enum: [DRAG_DROP] + instanceId: + type: string + sourceTabId: + type: string + targetTabId: + type: string + payload: + type: object + required: [serializedData, timestamp] + properties: + serializedData: + $ref: "#/components/schemas/SerializedData" + timestamp: + type: number + DragAbortMessage: + type: object + required: [type, instanceId, sourceTabId, payload] + properties: + type: + type: string + enum: [DRAG_ABORT] + instanceId: + type: string + sourceTabId: + type: string + payload: + type: object + required: [serializedData, timestamp] + properties: + serializedData: + $ref: "#/components/schemas/SerializedData" + timestamp: + type: number + HeartbeatMessage: + type: object + required: [type, instanceId, sourceTabId, payload] + properties: + type: + type: string + enum: [HEARTBEAT] + instanceId: + type: string + sourceTabId: + type: string + payload: + type: object + required: [timestamp] + properties: + timestamp: + type: number diff --git a/specs/002-magicdrag-refactor/data-model.md b/specs/002-magicdrag-refactor/data-model.md new file mode 100644 index 0000000..d86c223 --- /dev/null +++ b/specs/002-magicdrag-refactor/data-model.md @@ -0,0 +1,30 @@ +# Data Model + +## Entity: MagicDrag 子类 +- Fields: + - className: string(静态标识,注册 key) + - channelName: string(静态,用于跨 Tab 通信) + - onEnterTab?: (payload) => void(静态扩展点) + - onOtherTabDragStart?: (payload) => void(实例扩展点) + - onOtherTabDragMove?: (payload) => void(实例扩展点) + - onOtherTabDragEnd?: (payload) => void(实例扩展点) +- Relationships: + - 由 Manager 注册并与 channelName 绑定 + +## Entity: Manager 注册表 +- Fields: + - classesByName: Map + - classNameByChannel: Map + - instancesByClassName: Map> +- Validation Rules: + - channelName 必须唯一(冲突拒绝注册) + - 未注册 className 的消息必须被忽略并记录 warning + +## Entity: 跨 Tab 拖拽事件 +- Fields: + - type: DRAG_START | DRAG_MOVE | DRAG_END | DRAG_ENTER_TAB + - className: string + - payload: serialized data + screenPosition + dragOffset +- State Transitions: + - DRAG_START → DRAG_MOVE* → DRAG_END + - DRAG_ENTER_TAB(独立触发) diff --git a/specs/002-magicdrag-refactor/plan.md b/specs/002-magicdrag-refactor/plan.md new file mode 100644 index 0000000..24b40fe --- /dev/null +++ b/specs/002-magicdrag-refactor/plan.md @@ -0,0 +1,70 @@ +# Implementation Plan: MagicDrag API Decoupling + +**Branch**: `002-magicdrag-refactor` | **Date**: 2026-01-21 | **Spec**: /home/zhangxiao/frontend/SysUI/magic-drag/specs/002-magicdrag-refactor/spec.md +**Input**: Feature specification from `/specs/002-magicdrag-refactor/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +解耦 MagicDrag 与 Manager:Manager 通过注册子类获取静态 channelName 并调用可选扩展点(静态 onEnterTab 与实例 onOtherTabDrag*),未实现时安全跳过;跨 Tab 消息需按 className 分发并记录未注册类型。 + +## Technical Context + +**Language/Version**: TypeScript 5.6.3 +**Primary Dependencies**: @system-ui-js/multi-drag, BroadcastChannel API, @system-ui-js/development-base +**Storage**: N/A +**Testing**: Jest +**Target Platform**: Modern browsers (BroadcastChannel 支持) +**Project Type**: single library package +**Performance Goals**: 跨 Tab 回调 1s 内触发 +**Constraints**: BroadcastChannel 不可用时降级为单 Tab 行为 +**Scale/Scope**: 单库、跨 Tab 拖拽事件分发 + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- I. Library-First, Clear Boundaries: Manager 仅做协调/分发,MagicDrag 子类保持序列化与 DOM 归属,满足职责边界。 +- II. Type Safety First: 公共 API 与事件负载显式类型,禁止 any 与不安全断言。 +- III. Event-Driven Extensibility: 通过注册表按 className/channelName 分发事件,避免硬编码分支。 +- IV. Testing Discipline: spec 含用户场景,后续 tasks 必须补充/更新测试覆盖核心流程。 +- V. Observability & Diagnostics: 未注册 className 与通道不可用需记录包含 className/channelName 的诊断信息。 + +Re-check (post Phase 1): 设计产物已明确实体/契约与 quickstart,未引入与原则冲突的设计。 +## Project Structure + +### Documentation (this feature) + +```text +specs/002-magicdrag-refactor/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +src/ +├── magic-drag/ +│ ├── index.ts +│ ├── types.ts +│ ├── MagicDrag.ts +│ └── MagicDragManager.ts +└── __tests__/ + └── magic-drag.test.ts +``` + +**Structure Decision**: 单库结构,核心逻辑在 `src/magic-drag/`,测试在 `src/__tests__/`。 + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| N/A | N/A | N/A | diff --git a/specs/002-magicdrag-refactor/quickstart.md b/specs/002-magicdrag-refactor/quickstart.md new file mode 100644 index 0000000..8635671 --- /dev/null +++ b/specs/002-magicdrag-refactor/quickstart.md @@ -0,0 +1,51 @@ +# Quickstart + +## Goal +通过 Manager 注册 MagicDrag 子类,使用静态 channelName 进行跨 Tab 通信,并在目标 Tab 触发 onEnterTab 与其他 Tab 拖拽回调。 + +## Steps +1. 定义 MagicDrag 子类(包含静态 channelName 与序列化逻辑) +2. 在应用启动阶段注册子类到 Manager +3. 实例化子类并启用拖拽 +4. 跨 Tab 拖拽触发回调 + +## Example + +```ts +import { MagicDrag, MagicDragManager } from 'magic-drag' + +interface CardData { + title: string +} + +class MyCard extends MagicDrag { + static readonly channelName = 'my-card-channel' + + protected getClassName(): string { + return 'MyCard' + } + + serialize() { + return this.createSerializedData({ title: 'hello' }) + } + + deserialize(data) { + // custom logic + } + + static onEnterTab() { + // called when drag enters current tab + } + + onOtherTabDragStart() {} + onOtherTabDragMove() {} + onOtherTabDragEnd() {} +} + +const manager = MagicDragManager.getInstance() +manager.registerClass('MyCard', MyCard) + +const element = document.createElement('div') +document.body.appendChild(element) +const instance = new MyCard(element) +``` diff --git a/specs/002-magicdrag-refactor/research.md b/specs/002-magicdrag-refactor/research.md new file mode 100644 index 0000000..893a57c --- /dev/null +++ b/specs/002-magicdrag-refactor/research.md @@ -0,0 +1,21 @@ +# Research + +## Decision: 继续使用 BroadcastChannel 进行同源跨 Tab 通信 +- Rationale: 浏览器原生 API,已被现有库使用,满足同源多 Tab 通信;spec 也要求 BroadcastChannel 不可用时降级。 +- Alternatives considered: SharedWorker、ServiceWorker、window.postMessage(复杂度更高或适配性较差)。 + +## Decision: 使用序列化载荷进行跨 Tab 消息传输 +- Rationale: BroadcastChannel 仅支持结构化克隆;拖拽数据应保持可序列化,符合现有 message 设计。 +- Alternatives considered: 传 DOM 引用或函数(不被支持)。 + +## Decision: 以子类静态 channelName 建立通道并检测冲突 +- Rationale: FR-003/FR-003a 明确要求;可避免跨类串扰。 +- Alternatives considered: 全局单通道 + 运行时过滤(增加串扰风险)。 + +## Decision: 跨 Tab 回调同步触发与同类广播 +- Rationale: FR-005/FR-005a/FR-005b 明确同步触发并广播到同类实例;未实现扩展点需安全跳过。 +- Alternatives considered: 异步触发、仅单实例分发。 + +## Decision: 未注册 className 与通道不可用的处理 +- Rationale: FR-008/FR-009 明确要求记录 warning/error 并忽略或降级。 +- Alternatives considered: 静默忽略、抛异常中断流程。 diff --git a/specs/002-magicdrag-refactor/spec.md b/specs/002-magicdrag-refactor/spec.md new file mode 100644 index 0000000..2d1c9c0 --- /dev/null +++ b/specs/002-magicdrag-refactor/spec.md @@ -0,0 +1,111 @@ +# Feature Specification: MagicDrag API Decoupling + +**Feature Branch**: `001-magicdrag-refactor` +**Created**: 2026-01-21 +**Status**: Draft +**Input**: User description: "ulw 实现一些重构改造: +1. MagicDrag 基类不应该知道 Manager,只需暴露一些 API 给 Manager 使用即可 +2. channelName 直接作为静态属性放在类定义中,Manager 在注册 MagicDrag 子类和绑定通信的时候直接使用这个静态属性即可 +3. 用户在使用时,首先要在 Manager 上注册子类 +4. Manager 会调用 MagicDrag 子类的一些方法,可以是静态方法或非静态方法。注意这些给用户使用的方法在父类不应该有内容,而是留给子类定义用的 +5. Manager 调用 MagicDrag 的静态方法 onEnterTab,在拖拽进当前 Tab 时触发 +6. Manager 调用 MagicDrag 的子类属性 onOtherTabDragStart, onOtherTabDragMove, onOtherTabDragEnd,代表在其他 Tab 中,有相同的子类进行了一些拖拽操作" + +## Clarifications + +### Session 2026-01-21 + +- Q: 当多个子类共享相同 channelName 时,Manager 如何处理冲突? → A: 注册时检测冲突并拒绝注册 +- Q: 子类未实现扩展点时如何处理? → A: 未实现则安全跳过 +- Q: 跨 Tab 回调应同步还是异步触发? → A: 同步调用 +- Q: 同一 Tab 内多个同类实例时,其他 Tab 拖拽回调如何分发? → A: 触发同一类的所有已注册实例回调 +- Q: 未注册 className 的跨 Tab 消息如何处理? → A: 记录包含 className 的 warning 并忽略该消息 +- Q: BroadcastChannel 不可用或初始化失败时如何处理? → A: 记录 error 并降级为单 Tab 行为 + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - 注册子类并完成跨 Tab 拖拽 (Priority: P1) + +库使用者希望先在 Manager 中注册自定义拖拽子类,再进行跨 Tab 拖拽操作,并能在目标 Tab 正确触发进入回调。 + +**Why this priority**: 注册流程是所有跨 Tab 拖拽能力的前置条件,未完成注册无法使用功能。 + +**Independent Test**: 仅实现注册与进入事件即可验证最小可用流程:注册子类后,从其他 Tab 拖入当前 Tab 触发进入回调。 + +**Acceptance Scenarios**: + +1. **Given** Manager 已注册某个拖拽子类,**When** 另一 Tab 将该子类实例拖入当前 Tab,**Then** 该子类的进入回调被触发一次 +2. **Given** 未注册该子类,**When** 另一 Tab 将该子类实例拖入当前 Tab,**Then** 不触发进入回调且不会影响已注册子类 + +--- + +### User Story 2 - 观察其他 Tab 的拖拽状态 (Priority: P2) + +库使用者希望在当前 Tab 中得知其他 Tab 内相同子类的拖拽开始、移动和结束事件,以实现跨 Tab 状态联动。 + +**Why this priority**: 跨 Tab 协作是高级用法,需要在完成基础注册与进入流程后使用。 + +**Independent Test**: 仅实现并触发其他 Tab 拖拽状态回调即可验证该能力,不依赖进入回调。 + +**Acceptance Scenarios**: + +1. **Given** Manager 已注册某子类且该子类定义了其他 Tab 回调,**When** 另一 Tab 开始拖拽该子类实例,**Then** 当前 Tab 收到开始回调 +2. **Given** 当前 Tab 已收到开始回调,**When** 另一 Tab 结束拖拽该子类实例,**Then** 当前 Tab 收到结束回调 + +--- + +### User Story 3 - 子类独立定义可被 Manager 调用的扩展点 (Priority: P3) + +库使用者希望父类仅提供可被覆盖的 API,占位方法不包含默认逻辑,以便子类按需实现。 + +**Why this priority**: 这是架构要求,影响维护性,但不阻塞最基础功能。 + +**Independent Test**: 创建一个仅实现部分扩展点的子类,确保未实现的方法不会在父类中产生默认行为。 + +**Acceptance Scenarios**: + +1. **Given** 子类未实现某个扩展点,**When** Manager 调用该扩展点,**Then** 不产生任何默认行为 + +--- + +### Edge Cases + +- 当多个子类共享相同 channelName 时,注册流程应拒绝并明确报错 +- 当某子类未实现 onEnterTab 或其他 Tab 回调时,系统应安全跳过且不报错 + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: 系统必须允许在 Manager 中注册 MagicDrag 子类后才能参与跨 Tab 拖拽 +- **FR-002**: MagicDrag 基类必须不依赖 Manager,且仅暴露供 Manager 调用的扩展点 +- **FR-003**: 系统必须从子类的静态 channelName 获取通信通道名称并用于注册与通信绑定 +- **FR-003a**: 当注册的 channelName 与已注册子类冲突时,系统必须拒绝注册并给出明确错误 +- **FR-004**: Manager 必须在拖拽进入当前 Tab 时调用子类的静态 onEnterTab 扩展点 +- **FR-005**: Manager 必须在检测到其他 Tab 的拖拽开始、移动、结束时调用子类实例的 onOtherTabDragStart/onOtherTabDragMove/onOtherTabDragEnd +- **FR-005b**: 同一 Tab 内存在多个同类实例时,其他 Tab 的拖拽回调必须广播到该类所有已注册实例 +- **FR-005a**: 跨 Tab 回调触发应为同步调用 +- **FR-006**: 如果子类未实现某个扩展点,系统必须安全忽略该调用而不影响其他功能 +- **FR-007**: 未实现扩展点的调用不得抛出错误或中断其他子类回调 +- **FR-008**: 当接收到未注册 className 的跨 Tab 消息时,Manager 必须记录包含 className 的 warning 并忽略该消息 +- **FR-009**: 当 BroadcastChannel 不可用或初始化失败时,系统必须记录 error 并降级为单 Tab 行为 + +### Key Entities *(include if feature involves data)* + +- **MagicDrag 子类**: 用户自定义拖拽类型,包含静态 channelName 与可选扩展点 +- **Manager 注册表**: 记录已注册子类及其 channelName 的集合 +- **跨 Tab 拖拽事件**: 来自其他 Tab 的拖拽开始、移动、结束与进入事件 + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 用户在完成子类注册后,能在一次跨 Tab 拖拽中稳定触发进入回调 +- **SC-002**: 在其他 Tab 执行拖拽时,当前 Tab 能在 1 秒内收到开始与结束回调 +- **SC-003**: 未实现扩展点的子类不会导致运行时错误或阻断其他子类的回调 +- **SC-004**: 至少 90% 的跨 Tab 拖拽演示用例可在首次尝试中按预期触发对应回调 + +## Assumptions + +- 用户会在应用启动阶段完成子类注册 +- 拖拽事件能够在不同 Tab 之间可靠传递 diff --git a/specs/002-magicdrag-refactor/tasks.md b/specs/002-magicdrag-refactor/tasks.md new file mode 100644 index 0000000..b50e0d1 --- /dev/null +++ b/specs/002-magicdrag-refactor/tasks.md @@ -0,0 +1,166 @@ +--- + +description: "Tasks for MagicDrag API Decoupling" +--- + +# Tasks: MagicDrag API Decoupling + +**Input**: Design documents from `/specs/002-magicdrag-refactor/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: 用户场景为强制测试要求;需补充/更新单元测试。 + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [X] T001 确认现有项目结构与入口文件(src/magic-drag/index.ts, src/magic-drag/MagicDrag.ts, src/magic-drag/MagicDragManager.ts) +- [X] T002 [P] 盘点现有跨 Tab 消息类型与 payload 类型定义(src/magic-drag/types.ts) +- [X] T003 [P] 确认现有单元测试基线与覆盖范围(src/__tests__/magic-drag.test.ts) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 定义/完善 MagicDrag 子类扩展点类型与契约(src/magic-drag/types.ts) +- [X] T005 定义 Manager 注册表结构与冲突检测策略(src/magic-drag/MagicDragManager.ts) +- [X] T006 [P] 定义诊断日志规范(包含 className/channelName/messageType)(src/magic-drag/MagicDragManager.ts) + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - 注册子类并完成跨 Tab 拖拽 (Priority: P1) 🎯 MVP + +**Goal**: 完成子类注册与跨 Tab 进入回调触发的最小可用流程。 + +**Independent Test**: 注册子类后,从其他 Tab 拖入当前 Tab 触发 onEnterTab;未注册 className 消息被忽略并记录 warning。 + +### Tests for User Story 1 + +- [X] T007 [P] [US1] 新增注册与进入回调的单元测试(src/__tests__/magic-drag.test.ts) +- [X] T008 [P] [US1] 新增未注册 className 的 warning 行为测试(src/__tests__/magic-drag.test.ts) + +### Implementation for User Story 1 + +- [X] T009 [US1] 移除 MagicDrag 对 Manager 的耦合,保留可被调用的抽象扩展点(src/magic-drag/MagicDrag.ts) +- [X] T010 [US1] 读取子类静态 channelName 并注册,冲突时报错(src/magic-drag/MagicDragManager.ts) +- [X] T011 [US1] 处理 DRAG_ENTER_TAB 时调用子类静态 onEnterTab(src/magic-drag/MagicDragManager.ts) +- [X] T012 [US1] 未注册 className 消息记录 warning 并忽略(src/magic-drag/MagicDragManager.ts) + +**Checkpoint**: User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - 观察其他 Tab 的拖拽状态 (Priority: P2) + +**Goal**: 当前 Tab 能收到其他 Tab 的拖拽开始/移动/结束回调并广播到同类实例。 + +**Independent Test**: 同类实例在其他 Tab 拖拽时,当前 Tab 同类所有实例收到 onOtherTabDragStart/onOtherTabDragMove/onOtherTabDragEnd。 + +### Tests for User Story 2 + +- [X] T013 [P] [US2] 新增跨 Tab 拖拽开始/移动/结束回调测试(src/__tests__/magic-drag.test.ts) +- [X] T014 [P] [US2] 新增同类多实例广播测试(src/__tests__/magic-drag.test.ts) + +### Implementation for User Story 2 + +- [X] T015 [US2] 注册并维护同类实例集合(src/magic-drag/MagicDragManager.ts) +- [X] T016 [US2] 处理 DRAG_START/DRAG_MOVE/DRAG_END 调用实例 onOtherTabDrag*(src/magic-drag/MagicDragManager.ts) +- [X] T017 [US2] 同类实例回调同步广播(src/magic-drag/MagicDragManager.ts) + +**Checkpoint**: User Story 2 should be independently functional + +--- + +## Phase 5: User Story 3 - 子类独立定义可被 Manager 调用的扩展点 (Priority: P3) + +**Goal**: 父类扩展点仅占位无默认逻辑,未实现时安全跳过。 + +**Independent Test**: 子类未实现扩展点时不产生默认行为也不抛错。 + +### Tests for User Story 3 + +- [X] T018 [P] [US3] 新增未实现扩展点安全跳过测试(src/__tests__/magic-drag.test.ts) + +### Implementation for User Story 3 + +- [X] T019 [US3] 清理父类默认实现,确保调用时安全检测(src/magic-drag/MagicDrag.ts, src/magic-drag/MagicDragManager.ts) + +**Checkpoint**: User Story 3 should be independently functional + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [X] T020 [P] 更新公开类型与使用说明(src/magic-drag/types.ts, src/magic-drag/index.ts) +- [X] T021 [P] 补充日志上下文信息(src/magic-drag/MagicDragManager.ts) +- [X] T022 运行并修复单元测试(src/__tests__/magic-drag.test.ts) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion; P1 → P2 → P3 recommended +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) + +### Parallel Opportunities + +- Phase 1 tasks T002/T003 can run in parallel +- Phase 2 task T006 can run in parallel with T004/T005 +- Tests within a user story can run in parallel + +--- + +## Parallel Example: User Story 1 + +```text +Task: "新增注册与进入回调的单元测试(src/__tests__/magic-drag.test.ts)" +Task: "新增未注册 className 的 warning 行为测试(src/__tests__/magic-drag.test.ts)" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. STOP and validate User Story 1 independently + +### Incremental Delivery + +1. Setup + Foundational +2. User Story 1 → validate +3. User Story 2 → validate +4. User Story 3 → validate + +### Parallel Team Strategy + +- Setup/Foundational 完成后,可并行推进 US2/US3 From a1a663236ebef088f2fbae385e5ca87e0307f750 Mon Sep 17 00:00:00 2001 From: "Z.X" Date: Wed, 21 Jan 2026 12:04:11 +0000 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E4=B8=8E=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- AGENTS.md | 2 ++ src/main.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c59dee7..addabce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,8 @@ yarn format # 代码格式化 ## Active Technologies - TypeScript ~5.6.3 + @system-ui-js/multi-drag, @system-ui-js/development-base, BroadcastChannel API (001-manager-dispatch) +- TypeScript ~5.6.3 + @system-ui-js/multi-drag, BroadcastChannel API, @system-ui-js/development-base (002-magicdrag-refactor) +- TypeScript 5.6.3 + @system-ui-js/multi-drag, BroadcastChannel API, @system-ui-js/development-base (002-magicdrag-refactor) ## Recent Changes - 001-manager-dispatch: Added TypeScript ~5.6.3 + @system-ui-js/multi-drag, @system-ui-js/development-base, BroadcastChannel API diff --git a/src/main.ts b/src/main.ts index 44698bf..091392b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -79,13 +79,13 @@ class DemoCard extends MagicDrag { _screenPosition: ScreenPosition, _isLeaveTab: boolean ): void { - console.log(_screenPosition, _isLeaveTab) + // console.log(_screenPosition, _isLeaveTab) } protected onDragEnd( _screenPosition: ScreenPosition, _isLeaveTab: boolean ): void { - console.log('onDragEnd', _screenPosition, _isLeaveTab) + console.log('[onDragEnd]', _screenPosition, _isLeaveTab) } public onDragInInternal(): void { @@ -96,6 +96,7 @@ class DemoCard extends MagicDrag { this.initialLeft = this.element.style.left this.initialTop = this.element.style.top this.initialDisplay = this.element.style.display + console.log(`[onDragStart]`) } // 让元素回到拖拽开始时 From 59b49cc032d205203c24000b065a69107e8d426e Mon Sep 17 00:00:00 2001 From: "Z.X" Date: Wed, 21 Jan 2026 12:55:29 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8=20Tab=20?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=20onEnterTab=20=E5=9B=9E=E8=B0=83=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=B8=8E=20CI=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 handleExternalDragEnterTab 中错误的 sourceTabId 守卫,修复跨 Tab 进入时回调不被触发的问题 - 修复 ESLint sonarjs/public-static-readonly 错误:添加 readonly 修饰符 - 修复 TypeScript 类型错误:getClassName 访问级别从 protected 改为 public - 更新 CHANGELOG 记录所有修复项 --- CHANGELOG.md | 3 +++ src/__tests__/magic-drag.test.ts | 4 +-- src/magic-drag/MagicDrag.ts | 2 +- src/magic-drag/MagicDragManager.ts | 40 ++++++++++++++---------------- src/main.ts | 2 +- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d00b0b..848acdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - 修复 ESLint 错误:将 `MagicDrag.channelName` 改为 `readonly`,符合 sonarjs/public-static-readonly 规则 - 修复 ESLint 错误:移除冗余的类型别名 `MagicDragAnyEventListenerStore`,符合 sonarjs/redundant-type-aliases 规则 - 修复拖出所有 Tab 时无法触发 DRAG_ABORT 的问题:在 handleExternalDragMove 中,当鼠标离开当前 Tab 时,如果 `activeTabId` 等于当前 Tab ID,则将其设为 null,确保 MagicDrag.handleDragEnd 能正确判断 abort 条件 +- 修复跨 Tab 拖拽时 `onEnterTab` 回调不会被调用的问题:移除 `handleExternalDragEnterTab` 中不必要的 `sourceTabId` 守卫检查,因为该消息已通过初始过滤且语义本身就要求目标 Tab 响应进入事件 +- 修复 ESLint 错误:将测试文件中的 `TestCard.onEnterTab` 和 `MinimalCard.channelName` 改为 `readonly`,符合 sonarjs/public-static-readonly 规则 +- 修复 TypeScript 类型错误:将 `DemoCard.getClassName()` 从 `protected` 改为 `public`,以匹配基类中的 `public abstract getClassName()` 声明 ## 0.1.0 - 2025-11-16 diff --git a/src/__tests__/magic-drag.test.ts b/src/__tests__/magic-drag.test.ts index 1e8c852..9027983 100644 --- a/src/__tests__/magic-drag.test.ts +++ b/src/__tests__/magic-drag.test.ts @@ -78,7 +78,7 @@ interface TestCardData { } class TestCard extends MagicDrag { - static onEnterTab = jest.fn() + static readonly onEnterTab = jest.fn() private title: string private content: string @@ -416,7 +416,7 @@ describe('MagicDrag', () => { it('should allow missing optional extensions without errors', () => { class MinimalCard extends MagicDrag { - static channelName = 'minimal-card-channel' + static readonly channelName = 'minimal-card-channel' getClassName(): string { return 'MinimalCard' diff --git a/src/magic-drag/MagicDrag.ts b/src/magic-drag/MagicDrag.ts index 0524e02..bed7c7a 100644 --- a/src/magic-drag/MagicDrag.ts +++ b/src/magic-drag/MagicDrag.ts @@ -51,7 +51,7 @@ function generateUUID(): string { } export abstract class MagicDrag { - static channelName: string + static readonly channelName: string readonly instanceId: string = generateUUID() readonly element: HTMLElement diff --git a/src/magic-drag/MagicDragManager.ts b/src/magic-drag/MagicDragManager.ts index 2a4074c..df5dce6 100644 --- a/src/magic-drag/MagicDragManager.ts +++ b/src/magic-drag/MagicDragManager.ts @@ -644,29 +644,27 @@ export class MagicDragManager { } private handleExternalDragEnterTab(message: MagicDragMessage): void { - if (message.sourceTabId === this.dragState.sourceTabId) { - const serializedData = message.payload.serializedData - const className = serializedData?.className ?? null - const Constructor = className ? this.classRegistry.get(className) : null - if (!Constructor && className) { - console.warn(`[MagicDragManager] Unknown class: ${className}`) - } + const serializedData = message.payload.serializedData + const className = serializedData?.className ?? null + const Constructor = className ? this.classRegistry.get(className) : null + if (!Constructor && className) { + console.warn(`[MagicDragManager] Unknown class: ${className}`) + } - if (Constructor?.onEnterTab && serializedData) { - Constructor.onEnterTab(message.payload) - } + if (Constructor?.onEnterTab && serializedData) { + Constructor.onEnterTab(message.payload) + } - this.dragState.activeTabId = message.targetTabId ?? this.tabId - if ( - !this.previewInfo && - this.dragState.serializedData && - this.dragState.lastScreenPosition - ) { - this.createPreview( - this.dragState.lastScreenPosition, - this.dragState.serializedData - ) - } + this.dragState.activeTabId = message.targetTabId ?? this.tabId + if ( + !this.previewInfo && + this.dragState.serializedData && + this.dragState.lastScreenPosition + ) { + this.createPreview( + this.dragState.lastScreenPosition, + this.dragState.serializedData + ) } } diff --git a/src/main.ts b/src/main.ts index 091392b..003b57f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,7 +33,7 @@ class DemoCard extends MagicDrag { this.applyStyles() } - protected getClassName(): string { + public getClassName(): string { return 'DemoCard' }