From e54252dace7ed8b50f8cec5232e08043dbd09e02 Mon Sep 17 00:00:00 2001 From: markshawn2020 Date: Thu, 6 Nov 2025 11:25:49 +0800 Subject: [PATCH] feat: support mode switching with autoToggle option Add ability to toggle inspector activation mode via keyboard shortcuts. Users can now switch between always-on and hotkey-triggered modes without restarting the dev server. New option: - autoToggle (default: true): Enable mode switching via shortcut Changes: - Add mode toggle logic to client component - Add comprehensive tests for mode switching - Update type definitions --- packages/core/src/client/index.ts | 193 ++++++++++++++++-- packages/core/src/server/use-client.ts | 8 +- packages/core/src/shared/type.ts | 1 + packages/core/types/client/index.d.ts | 12 +- packages/core/types/shared/type.d.ts | 1 + .../client/index/handle-mouse-click.test.ts | 63 +++++- test/core/client/index/mode-shortcut.test.ts | 62 ++++++ test/core/client/index/properties.test.ts | 7 +- test/core/client/index/track-code.test.ts | 51 +++-- 9 files changed, 365 insertions(+), 33 deletions(-) create mode 100644 test/core/client/index/mode-shortcut.test.ts diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 83211074..739d91e9 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -67,6 +67,10 @@ interface ActiveNode { class?: 'tooltip-top' | 'tooltip-bottom'; } +type InspectorAction = 'copy' | 'locate' | 'target' | 'all'; +type TrackAction = InspectorAction | 'default'; +type ResolvedAction = InspectorAction | 'none'; + const PopperWidth = 300; function nextTick() { @@ -89,7 +93,9 @@ export class CodeInspectorComponent extends LitElement { @property() locate: boolean = true; @property() - copy: boolean | string = false; + copy: boolean | string = true; + @property({ attribute: 'default-action' }) + defaultAction: InspectorAction = 'copy'; @property() target: string = ''; @property() @@ -531,16 +537,41 @@ export class CodeInspectorComponent extends LitElement { }; // 触发功能的处理 - trackCode = () => { - if (this.locate) { - // 请求本地服务端,打开vscode + trackCode = (action: TrackAction = 'default') => { + let resolvedAction: ResolvedAction; + if (action === 'default') { + resolvedAction = this.getDefaultAction(); + } else if (action === 'all') { + resolvedAction = this.copy || this.locate || this.target ? 'all' : 'none'; + } else if (this.isActionEnabled(action)) { + resolvedAction = action; + } else { + resolvedAction = 'none'; + } + + if (resolvedAction === 'none') { + return; + } + + const shouldLocate = + (resolvedAction === 'locate' || resolvedAction === 'all') && this.locate; + const shouldCopy = + (resolvedAction === 'copy' || resolvedAction === 'all') && !!this.copy; + const shouldTarget = + (resolvedAction === 'target' || resolvedAction === 'all') && !!this.target; + + if (!shouldLocate && !shouldCopy && !shouldTarget) { + return; + } + + if (shouldLocate) { if (this.sendType === 'xhr') { this.sendXHR(); } else { this.sendImg(); } } - if (this.copy) { + if (shouldCopy) { const path = formatOpenPath( this.element.path, String(this.element.line), @@ -549,14 +580,133 @@ export class CodeInspectorComponent extends LitElement { ); this.copyToClipboard(path[0]); } - if (this.target) { + if (shouldTarget) { window.open(this.buildTargetUrl(), '_blank'); } - window.dispatchEvent(new CustomEvent('code-inspector:trackCode', { - detail: this.element, - })); + window.dispatchEvent( + new CustomEvent('code-inspector:trackCode', { + detail: this.element, + }) + ); + }; + + private getDefaultAction(): ResolvedAction { + const resolved = this.resolvePreferredAction(this.defaultAction); + if (resolved !== 'none' && resolved !== this.defaultAction) { + this.defaultAction = resolved; + } + return resolved; + } + + private isActionEnabled(action: Exclude): boolean { + if (action === 'copy') { + return !!this.copy; + } + if (action === 'locate') { + return !!this.locate; + } + return !!this.target; + } + + private resolvePreferredAction( + preferred: InspectorAction + ): ResolvedAction { + if (preferred === 'all') { + return this.copy || this.locate || this.target ? 'all' : 'none'; + } + if (this.isActionEnabled(preferred)) { + return preferred; + } + const fallbackOrder: Array> = [ + 'copy', + 'locate', + 'target', + ]; + for (const candidate of fallbackOrder) { + if (candidate !== preferred && this.isActionEnabled(candidate)) { + return candidate; + } + } + return 'none'; + } + + private getAvailableDefaultActions(): InspectorAction[] { + const actions: InspectorAction[] = []; + if (this.copy) { + actions.push('copy'); + } + if (this.locate) { + actions.push('locate'); + } + if (this.target) { + actions.push('target'); + } + if (actions.length > 1 && this.copy && this.locate) { + actions.push('all'); + } + return actions; + } + + private handleModeShortcut = (e: KeyboardEvent) => { + if (!e.shiftKey || !e.altKey) { + return; + } + const code = e.code?.toLowerCase(); + const key = e.key?.toLowerCase(); + const isCKey = code ? code === 'keyc' : key === 'c'; + if (!isCKey) { + return; + } + e.preventDefault(); + e.stopPropagation(); + const actions = this.getAvailableDefaultActions(); + if (actions.length <= 1) { + return; + } + const currentIndex = actions.indexOf(this.defaultAction); + const nextAction = + currentIndex === -1 + ? actions[0] + : actions[(currentIndex + 1) % actions.length]; + this.defaultAction = nextAction; + this.printModeChange(nextAction); }; + private printModeChange(action: InspectorAction) { + if (this.hideConsole) { + return; + } + const label = this.getActionLabel(action); + const agent = + typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : ''; + const isWindows = ['windows', 'win32', 'wow32', 'win64', 'wow64'].some( + (item) => agent.toUpperCase().includes(item.toUpperCase()) + ); + const shortcut = isWindows ? 'Shift+Alt+C' : 'Shift+Opt+C'; + console.log( + `%c[code-inspector-plugin]%c Mode switched to %c${label}%c (${shortcut})`, + 'color: #006aff; font-weight: bolder; font-size: 12px;', + 'color: #006aff; font-size: 12px;', + 'color: #00B42A; font-weight: bold; font-size: 12px;', + 'color: #006aff; font-size: 12px;' + ); + } + + private getActionLabel(action: ResolvedAction): string { + switch (action) { + case 'copy': + return 'Copy Path'; + case 'locate': + return 'Open in IDE'; + case 'target': + return 'Open Target Link'; + case 'all': + return 'Copy + Open'; + default: + return 'Disabled'; + } + } + copyToClipboard(text: string) { if (typeof navigator?.clipboard?.writeText === 'function') { navigator.clipboard.writeText(text); @@ -657,8 +807,10 @@ export class CodeInspectorComponent extends LitElement { e.stopPropagation(); // 阻止默认事件 e.preventDefault(); - // 唤醒 vscode - this.trackCode(); + const primaryAction = this.getDefaultAction(); + if (primaryAction !== 'none') { + this.trackCode(primaryAction as InspectorAction); + } // 清除遮罩层 this.removeCover(); if (this.autoToggle) { @@ -756,6 +908,7 @@ export class CodeInspectorComponent extends LitElement { const isWindows = ['windows', 'win32', 'wow32', 'win64', 'wow64'].some( (item) => agent.toUpperCase().match(item.toUpperCase()) ); + const modeShortcut = isWindows ? 'Shift+Alt+C' : 'Shift+Opt+C'; const hotKeyMap = isWindows ? WindowsHotKeyMap : MacHotKeyMap; const keys = this.hotKeys .split(',') @@ -771,10 +924,11 @@ export class CodeInspectorComponent extends LitElement { } }); const replacement = '%c'; + const currentMode = this.getActionLabel(this.getDefaultAction()); console.log( `${replacement}[code-inspector-plugin]${replacement}Press and hold ${keys.join( ` ${replacement}+ ` - )}${replacement} to enable the feature. (click on page elements to locate the source code in the editor)`, + )}${replacement} to enable the feature. (Current mode: ${currentMode}; press ${modeShortcut} to switch)`, 'color: #006aff; font-weight: bolder; font-size: 12px;', ...colors ); @@ -829,7 +983,7 @@ export class CodeInspectorComponent extends LitElement { handleClickTreeNode = (node: TreeNode) => { this.element = node; - this.trackCode(); + this.trackCode('locate'); this.removeLayerPanel(); }; @@ -883,6 +1037,7 @@ export class CodeInspectorComponent extends LitElement { window.addEventListener('click', this.handleMouseClick, true); window.addEventListener('pointerdown', this.handlePointerDown, true); window.addEventListener('keyup', this.handleKeyUp, true); + window.addEventListener('keydown', this.handleModeShortcut, true); window.addEventListener('mouseleave', this.removeCover, true); window.addEventListener('mouseup', this.handleMouseUp, true); window.addEventListener('touchend', this.handleMouseUp, true); @@ -897,6 +1052,7 @@ export class CodeInspectorComponent extends LitElement { window.removeEventListener('click', this.handleMouseClick, true); window.removeEventListener('pointerdown', this.handlePointerDown, true); window.removeEventListener('keyup', this.handleKeyUp, true); + window.removeEventListener('keydown', this.handleModeShortcut, true); window.removeEventListener('mouseleave', this.removeCover, true); window.removeEventListener('mouseup', this.handleMouseUp, true); window.removeEventListener('touchend', this.handleMouseUp, true); @@ -967,6 +1123,13 @@ export class CodeInspectorComponent extends LitElement { bottom: this.activeNode.bottom, display: this.showNodeTree ? '' : 'none', }; + const resolvedDefaultAction = this.getDefaultAction(); + const modeLabel = this.getActionLabel(resolvedDefaultAction); + const modeShortcut = + typeof navigator !== 'undefined' && + /mac|iphone|ipad|ipod/i.test(navigator.userAgent) + ? 'Shift+Opt+C' + : 'Shift+Alt+C'; return html`
<${this.element.name}> - click to open editor + + Mode: ${modeLabel} · ${modeShortcut} to switch +
diff --git a/packages/core/src/server/use-client.ts b/packages/core/src/server/use-client.ts index 9692d8cd..74fdc52d 100644 --- a/packages/core/src/server/use-client.ts +++ b/packages/core/src/server/use-client.ts @@ -147,7 +147,12 @@ export function getWebComponentCode(options: CodeOptions, port: number) { ip = false, bundler, } = options || ({} as CodeOptions); - const { locate = true, copy = false, target = '' } = behavior; + const { + locate = true, + copy = true, + target = '', + defaultAction = 'copy', + } = behavior; return ` ;(function (){ if (typeof window !== 'undefined') { @@ -164,6 +169,7 @@ export function getWebComponentCode(options: CodeOptions, port: number) { inspector.copy = ${typeof copy === 'string' ? `'${copy}'` : !!copy}; inspector.target = '${target}'; inspector.ip = '${getIP(ip)}'; + inspector.defaultAction = ${JSON.stringify(defaultAction)}; document.documentElement.append(inspector); } } diff --git a/packages/core/src/shared/type.ts b/packages/core/src/shared/type.ts index 0584d37b..b559405b 100644 --- a/packages/core/src/shared/type.ts +++ b/packages/core/src/shared/type.ts @@ -6,6 +6,7 @@ export type Behavior = { locate?: boolean; copy?: boolean | string; target?: string; + defaultAction?: 'copy' | 'locate' | 'target' | 'all'; }; export type RecordInfo = { port: number; diff --git a/packages/core/types/client/index.d.ts b/packages/core/types/client/index.d.ts index 84d327bb..66b8db91 100644 --- a/packages/core/types/client/index.d.ts +++ b/packages/core/types/client/index.d.ts @@ -35,6 +35,8 @@ interface ActiveNode { visibility?: 'visible' | 'hidden'; class?: 'tooltip-top' | 'tooltip-bottom'; } +type InspectorAction = 'copy' | 'locate' | 'target' | 'all'; +type TrackAction = InspectorAction | 'default'; export declare class CodeInspectorComponent extends LitElement { hotKeys: string; port: number; @@ -43,6 +45,7 @@ export declare class CodeInspectorComponent extends LitElement { hideConsole: boolean; locate: boolean; copy: boolean | string; + defaultAction: InspectorAction; target: string; ip: string; position: { @@ -133,7 +136,14 @@ export declare class CodeInspectorComponent extends LitElement { sendXHR: () => void; sendImg: () => void; buildTargetUrl: () => string; - trackCode: () => void; + trackCode: (action?: TrackAction) => void; + private getDefaultAction; + private isActionEnabled; + private resolvePreferredAction; + private getAvailableDefaultActions; + private handleModeShortcut; + private printModeChange; + private getActionLabel; copyToClipboard(text: string): void; handleDrag: (e: MouseEvent | TouchEvent) => void; isSamePositionNode: (node1: HTMLElement, node2: HTMLElement) => boolean; diff --git a/packages/core/types/shared/type.d.ts b/packages/core/types/shared/type.d.ts index 4a23d208..983880ee 100644 --- a/packages/core/types/shared/type.d.ts +++ b/packages/core/types/shared/type.d.ts @@ -6,6 +6,7 @@ export type Behavior = { locate?: boolean; copy?: boolean | string; target?: string; + defaultAction?: 'copy' | 'locate' | 'target' | 'all'; }; export type RecordInfo = { port: number; diff --git a/test/core/client/index/handle-mouse-click.test.ts b/test/core/client/index/handle-mouse-click.test.ts index 72732bfa..006c607d 100644 --- a/test/core/client/index/handle-mouse-click.test.ts +++ b/test/core/client/index/handle-mouse-click.test.ts @@ -16,6 +16,63 @@ describe('handleMouseClick', () => { component.removeCover = vi.fn(); }); + describe('Preferred Action Logic', () => { + it('should fallback to locate when copy is disabled', () => { + component.show = true; + component.copy = false; + component.locate = true; + component.defaultAction = 'copy'; + const mouseEvent = new MouseEvent('click'); + mouseEvent.stopPropagation = vi.fn(); + mouseEvent.preventDefault = vi.fn(); + + component.handleMouseClick(mouseEvent); + + expect(component.trackCode).toHaveBeenCalledWith('locate'); + }); + + it('should respect an explicit locate defaultAction', () => { + component.show = true; + component.defaultAction = 'locate'; + const mouseEvent = new MouseEvent('click'); + mouseEvent.stopPropagation = vi.fn(); + mouseEvent.preventDefault = vi.fn(); + + component.handleMouseClick(mouseEvent); + + expect(component.trackCode).toHaveBeenCalledWith('locate'); + }); + + it('should fallback to target when locate is disabled', () => { + component.show = true; + component.copy = false; + component.locate = false; + component.target = 'https://example.com/{file}'; + component.defaultAction = 'locate'; + const mouseEvent = new MouseEvent('click'); + mouseEvent.stopPropagation = vi.fn(); + mouseEvent.preventDefault = vi.fn(); + + component.handleMouseClick(mouseEvent); + + expect(component.trackCode).toHaveBeenCalledWith('target'); + }); + + it('should skip trackCode when no actions are enabled', () => { + component.show = true; + component.copy = false; + component.locate = false; + component.target = ''; + const mouseEvent = new MouseEvent('click'); + mouseEvent.stopPropagation = vi.fn(); + mouseEvent.preventDefault = vi.fn(); + + component.handleMouseClick(mouseEvent); + + expect(component.trackCode).not.toHaveBeenCalled(); + }); + }); + afterEach(() => { document.body.removeChild(component); vi.clearAllMocks(); @@ -32,7 +89,7 @@ describe('handleMouseClick', () => { expect(mouseEvent.stopPropagation).toHaveBeenCalled(); expect(mouseEvent.preventDefault).toHaveBeenCalled(); - expect(component.trackCode).toHaveBeenCalled(); + expect(component.trackCode).toHaveBeenCalledWith('copy'); expect(component.removeCover).toHaveBeenCalled(); }); @@ -47,7 +104,7 @@ describe('handleMouseClick', () => { expect(mouseEvent.stopPropagation).toHaveBeenCalled(); expect(mouseEvent.preventDefault).toHaveBeenCalled(); - expect(component.trackCode).toHaveBeenCalled(); + expect(component.trackCode).toHaveBeenCalledWith('copy'); expect(component.removeCover).toHaveBeenCalled(); }); }); @@ -153,4 +210,4 @@ describe('handleMouseClick', () => { ]); }); }); -}); \ No newline at end of file +}); diff --git a/test/core/client/index/mode-shortcut.test.ts b/test/core/client/index/mode-shortcut.test.ts new file mode 100644 index 00000000..f96745d1 --- /dev/null +++ b/test/core/client/index/mode-shortcut.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CodeInspectorComponent } from '@/core/src/client'; + +describe('mode shortcut', () => { + let component: CodeInspectorComponent; + + beforeEach(() => { + component = new CodeInspectorComponent(); + component.hideConsole = true; + document.body.appendChild(component); + }); + + afterEach(() => { + document.body.removeChild(component); + }); + + it('cycles between copy and locate', () => { + component.copy = true; + component.locate = true; + component.defaultAction = 'copy'; + + const event = { + key: 'c', + code: 'KeyC', + altKey: true, + shiftKey: true, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + + (component as any).handleModeShortcut(event); + expect(component.defaultAction).toBe('locate'); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + + (component as any).handleModeShortcut(event); + expect(component.defaultAction).toBe('all'); + + (component as any).handleModeShortcut(event); + expect(component.defaultAction).toBe('copy'); + }); + + it('ignores shortcut when only one action available', () => { + component.copy = true; + component.locate = false; + component.defaultAction = 'copy'; + + const event = { + key: 'c', + code: 'KeyC', + altKey: true, + shiftKey: true, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + + (component as any).handleModeShortcut(event); + expect(component.defaultAction).toBe('copy'); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); +}); diff --git a/test/core/client/index/properties.test.ts b/test/core/client/index/properties.test.ts index 4eee4ebe..a82c48ae 100644 --- a/test/core/client/index/properties.test.ts +++ b/test/core/client/index/properties.test.ts @@ -23,7 +23,8 @@ describe('properties', () => { expect(component.autoToggle).toBe(false); expect(component.hideConsole).toBe(false); expect(component.locate).toBe(true); - expect(component.copy).toBe(false); + expect(component.copy).toBe(true); + expect(component.defaultAction).toBe('copy'); expect(component.ip).toBe('localhost'); }); @@ -35,6 +36,7 @@ describe('properties', () => { component.hideConsole = true; component.locate = false; component.copy = true; + component.defaultAction = 'locate'; component.ip = '192.168.1.100'; expect(component.hotKeys).toBe('altKey'); @@ -44,6 +46,7 @@ describe('properties', () => { expect(component.hideConsole).toBe(true); expect(component.locate).toBe(false); expect(component.copy).toBe(true); + expect(component.defaultAction).toBe('locate'); expect(component.ip).toBe('192.168.1.100'); }); -}); \ No newline at end of file +}); diff --git a/test/core/client/index/track-code.test.ts b/test/core/client/index/track-code.test.ts index 7dfc2f15..6a7ab908 100644 --- a/test/core/client/index/track-code.test.ts +++ b/test/core/client/index/track-code.test.ts @@ -14,6 +14,7 @@ describe('trackCode', () => { let component: CodeInspectorComponent; beforeEach(() => { + vi.clearAllMocks(); // 创建组件实例 component = new CodeInspectorComponent(); @@ -38,7 +39,7 @@ describe('trackCode', () => { it('should call sendXHR when sendType is xhr', () => { component.sendType = 'xhr'; - component.trackCode(); + component.trackCode('locate'); expect(component.sendXHR).toHaveBeenCalled(); expect(component.sendImg).not.toHaveBeenCalled(); @@ -46,7 +47,7 @@ describe('trackCode', () => { it('should call sendImg when sendType is img', () => { component.sendType = 'img'; - component.trackCode(); + component.trackCode('locate'); expect(component.sendImg).toHaveBeenCalled(); expect(component.sendXHR).not.toHaveBeenCalled(); @@ -55,7 +56,7 @@ describe('trackCode', () => { it('should not call any send method when locate is false', () => { component.locate = false; component.sendType = 'xhr'; - component.trackCode(); + component.trackCode('locate'); expect(component.sendXHR).not.toHaveBeenCalled(); expect(component.sendImg).not.toHaveBeenCalled(); @@ -69,7 +70,7 @@ describe('trackCode', () => { it('should not call formatOpenPath when copy is false', () => { component.copy = false; - component.trackCode(); + component.trackCode('copy'); expect(formatOpenPath).not.toHaveBeenCalled(); expect(component.copyToClipboard).not.toHaveBeenCalled(); @@ -79,7 +80,7 @@ describe('trackCode', () => { component.locate = false; component.copy = false; - component.trackCode(); + component.trackCode('copy'); expect(component.sendXHR).not.toHaveBeenCalled(); expect(component.sendImg).not.toHaveBeenCalled(); @@ -88,7 +89,7 @@ describe('trackCode', () => { }); it('should call formatOpenPath and copyToClipboard when copy is true', () => { - component.trackCode(); + component.trackCode('copy'); expect(formatOpenPath).toHaveBeenCalledWith( '/path/to/file.ts', @@ -98,6 +99,32 @@ describe('trackCode', () => { ); expect(component.copyToClipboard).toHaveBeenCalledWith('formatted/path:10:5'); }); + + it('should default to copy when no action is provided', () => { + component.locate = true; + component.copy = true; + component.defaultAction = 'copy'; + component.sendType = 'xhr'; + + component.trackCode(); + + expect(formatOpenPath).toHaveBeenCalled(); + expect(component.copyToClipboard).toHaveBeenCalled(); + expect(component.sendXHR).not.toHaveBeenCalled(); + }); + + it('should fallback to locate when copy is disabled', () => { + component.copy = false; + component.locate = true; + component.defaultAction = 'copy'; + component.sendType = 'xhr'; + + component.trackCode(); + + expect(component.sendXHR).toHaveBeenCalled(); + expect(component.copyToClipboard).not.toHaveBeenCalled(); + expect(formatOpenPath).not.toHaveBeenCalled(); + }); }); describe('Combined Functionality', () => { @@ -106,7 +133,7 @@ describe('trackCode', () => { component.copy = true; component.sendType = 'xhr'; - component.trackCode(); + component.trackCode('all'); expect(component.sendXHR).toHaveBeenCalled(); expect(formatOpenPath).toHaveBeenCalled(); @@ -120,7 +147,7 @@ describe('trackCode', () => { // @ts-ignore component.element = {}; - component.trackCode(); + component.trackCode('copy'); expect(formatOpenPath).toHaveBeenCalledWith( undefined, @@ -135,7 +162,7 @@ describe('trackCode', () => { // @ts-ignore component.sendType = 'invalid'; - component.trackCode(); + component.trackCode('locate'); expect(component.sendXHR).not.toHaveBeenCalled(); expect(component.sendImg).toHaveBeenCalled(); @@ -152,7 +179,7 @@ describe('trackCode', () => { name: 'test' }; - component.trackCode(); + component.trackCode('copy'); expect(formatOpenPath).toHaveBeenCalledWith( '/path/to/file.ts', @@ -173,7 +200,7 @@ describe('trackCode', () => { name: 'test' }; - component.trackCode(); + component.trackCode('copy'); expect(formatOpenPath).toHaveBeenCalledWith( '/path/to/file.ts', @@ -183,4 +210,4 @@ describe('trackCode', () => { ); }); }); -}); \ No newline at end of file +});