diff --git a/app/chrome-extension/common/web-editor-types.ts b/app/chrome-extension/common/web-editor-types.ts new file mode 100644 index 0000000..bb81b60 --- /dev/null +++ b/app/chrome-extension/common/web-editor-types.ts @@ -0,0 +1,262 @@ +/** + * Web Editor V2 - Shared Type Definitions + * + * This module defines types shared between: + * - Background script (injection control) + * - Inject script (web-editor-v2.ts) + * - Future: UI panels + */ + +// ============================================================================= +// Editor State +// ============================================================================= + +/** Current state of the web editor */ +export interface WebEditorState { + /** Whether the editor is currently active */ + active: boolean; + /** Editor version for compatibility checks */ + version: 2; +} + +// ============================================================================= +// Message Protocol (Background <-> Inject Script) +// ============================================================================= + +/** + * Action types for web editor V2 messages + * + * IMPORTANT: V2 uses versioned action names (suffix _v2) to avoid + * conflicts with V1 when both scripts might be injected in the same tab. + * This prevents double-response race conditions. + * + * V1 uses: web_editor_ping, web_editor_toggle, etc. + * V2 uses: web_editor_ping_v2, web_editor_toggle_v2, etc. + */ +export const WEB_EDITOR_V2_ACTIONS = { + /** Check if V2 editor is injected and get status */ + PING: 'web_editor_ping_v2', + /** Toggle V2 editor on/off */ + TOGGLE: 'web_editor_toggle_v2', + /** Start V2 editor */ + START: 'web_editor_start_v2', + /** Stop V2 editor */ + STOP: 'web_editor_stop_v2', +} as const; + +/** + * Legacy V1 action types (for reference and background compatibility) + * These are used when USE_WEB_EDITOR_V2 is false + */ +export const WEB_EDITOR_V1_ACTIONS = { + PING: 'web_editor_ping', + TOGGLE: 'web_editor_toggle', + START: 'web_editor_start', + STOP: 'web_editor_stop', + APPLY: 'web_editor_apply', +} as const; + +export type WebEditorV2Action = (typeof WEB_EDITOR_V2_ACTIONS)[keyof typeof WEB_EDITOR_V2_ACTIONS]; +export type WebEditorV1Action = (typeof WEB_EDITOR_V1_ACTIONS)[keyof typeof WEB_EDITOR_V1_ACTIONS]; + +/** Editor version literal type */ +export type WebEditorVersion = 1 | 2; + +/** Ping request (V2) */ +export interface WebEditorV2PingRequest { + action: typeof WEB_EDITOR_V2_ACTIONS.PING; +} + +/** Ping response (V2) */ +export interface WebEditorV2PingResponse { + status: 'pong'; + active: boolean; + version: 2; +} + +/** Toggle request (V2) */ +export interface WebEditorV2ToggleRequest { + action: typeof WEB_EDITOR_V2_ACTIONS.TOGGLE; +} + +/** Toggle response (V2) */ +export interface WebEditorV2ToggleResponse { + active: boolean; +} + +/** Start request (V2) */ +export interface WebEditorV2StartRequest { + action: typeof WEB_EDITOR_V2_ACTIONS.START; +} + +/** Start response (V2) */ +export interface WebEditorV2StartResponse { + active: boolean; +} + +/** Stop request (V2) */ +export interface WebEditorV2StopRequest { + action: typeof WEB_EDITOR_V2_ACTIONS.STOP; +} + +/** Stop response (V2) */ +export interface WebEditorV2StopResponse { + active: boolean; +} + +/** Union types for V2 type-safe message handling */ +export type WebEditorV2Request = + | WebEditorV2PingRequest + | WebEditorV2ToggleRequest + | WebEditorV2StartRequest + | WebEditorV2StopRequest; + +export type WebEditorV2Response = + | WebEditorV2PingResponse + | WebEditorV2ToggleResponse + | WebEditorV2StartResponse + | WebEditorV2StopResponse; + +// ============================================================================= +// Element Locator (Phase 1 - Basic Structure) +// ============================================================================= + +/** + * Framework debug source information + * Extracted from React Fiber or Vue component instance + */ +export interface DebugSource { + /** Source file path */ + file: string; + /** Line number (1-based) */ + line?: number; + /** Column number (1-based) */ + column?: number; + /** Component name (if available) */ + componentName?: string; +} + +/** + * Element Locator - Primary key for element identification + * + * Uses multiple strategies to locate elements, supporting: + * - HMR/DOM changes recovery + * - Cross-session persistence + * - Framework-agnostic identification + */ +export interface ElementLocator { + /** CSS selector candidates (ordered by specificity) */ + selectors: string[]; + /** Structural fingerprint for similarity matching */ + fingerprint: string; + /** Framework debug information (React/Vue) */ + debugSource?: DebugSource; + /** DOM tree path (child indices from root) */ + path: number[]; + /** iframe selector chain (from top to target frame) - Phase 4 */ + frameChain?: string[]; + /** Shadow DOM host selector chain - Phase 2 */ + shadowHostChain?: string[]; +} + +// ============================================================================= +// Transaction System (Phase 1 - Basic Structure, Low Priority) +// ============================================================================= + +/** Transaction operation types */ +export type TransactionType = 'style' | 'text' | 'move' | 'structure'; + +/** + * Transaction snapshot for undo/redo + * Captures element state before/after changes + */ +export interface TransactionSnapshot { + /** Element locator for re-identification */ + locator: ElementLocator; + /** innerHTML snapshot (for structure changes) */ + html?: string; + /** Changed style properties */ + styles?: Record; + /** Text content */ + text?: string; +} + +/** + * Move operation data + * Captures parent/position for element moves + */ +export interface MoveOperationData { + /** Target parent element locator */ + parentLocator: ElementLocator; + /** Insert position index */ + insertIndex: number; + /** Anchor sibling element locator */ + anchorLocator?: ElementLocator; + /** Position relative to anchor */ + anchorPosition: 'before' | 'after'; +} + +/** + * Structure operation data + * For wrap/unwrap/delete/duplicate operations + */ +export interface StructureOperationData { + action: 'wrap' | 'unwrap' | 'delete' | 'duplicate'; + /** Wrapper tag for wrap action */ + wrapperTag?: string; + /** Wrapper styles for wrap action */ + wrapperStyles?: Record; +} + +/** + * Transaction record for undo/redo system + */ +export interface Transaction { + /** Unique transaction ID */ + id: string; + /** Operation type */ + type: TransactionType; + /** Target element locator */ + targetLocator: ElementLocator; + /** State before change */ + before: TransactionSnapshot; + /** State after change */ + after: TransactionSnapshot; + /** Move-specific data */ + moveData?: MoveOperationData; + /** Structure-specific data */ + structureData?: StructureOperationData; + /** Timestamp */ + timestamp: number; + /** Whether merged with previous transaction */ + merged: boolean; +} + +// ============================================================================= +// Public API Interface +// ============================================================================= + +/** + * Web Editor V2 Public API + * Exposed on window.__MCP_WEB_EDITOR_V2__ + */ +export interface WebEditorV2Api { + /** Start the editor */ + start: () => void; + /** Stop the editor */ + stop: () => void; + /** Toggle editor on/off, returns new state */ + toggle: () => boolean; + /** Get current state */ + getState: () => WebEditorState; +} + +// ============================================================================= +// Global Declaration +// ============================================================================= + +declare global { + interface Window { + __MCP_WEB_EDITOR_V2__?: WebEditorV2Api; + } +} diff --git a/app/chrome-extension/entrypoints/background/web-editor/index.ts b/app/chrome-extension/entrypoints/background/web-editor/index.ts index 105d8e9..62c5028 100644 --- a/app/chrome-extension/entrypoints/background/web-editor/index.ts +++ b/app/chrome-extension/entrypoints/background/web-editor/index.ts @@ -1,9 +1,26 @@ import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; +import { WEB_EDITOR_V2_ACTIONS, WEB_EDITOR_V1_ACTIONS } from '@/common/web-editor-types'; const CONTEXT_MENU_ID = 'web_editor_toggle'; const COMMAND_KEY = 'toggle_web_editor'; const DEFAULT_NATIVE_SERVER_PORT = 12306; +/** + * Web Editor version configuration + * - v1: Legacy inject-scripts/web-editor.js (IIFE, ~850 lines) + * - v2: New TypeScript-based web-editor-v2.js (WXT unlisted script) + * + * Set USE_WEB_EDITOR_V2 to true to enable v2. + * This flag allows gradual rollout and easy rollback. + */ +const USE_WEB_EDITOR_V2 = true; + +/** Script path for v1 (legacy) */ +const V1_SCRIPT_PATH = 'inject-scripts/web-editor.js'; + +/** Script path for v2 (WXT unlisted script output) */ +const V2_SCRIPT_PATH = 'web-editor-v2.js'; + type WebEditorInstructionType = 'update_text' | 'update_style'; interface WebEditorFingerprint { @@ -179,40 +196,69 @@ async function ensureContextMenu(): Promise { } } +/** + * Get the appropriate action constants based on version + */ +function getActions() { + return USE_WEB_EDITOR_V2 ? WEB_EDITOR_V2_ACTIONS : WEB_EDITOR_V1_ACTIONS; +} + +/** + * Ensure the web editor script is injected into the tab + * Supports both v1 (legacy) and v2 (new) versions + * + * V1 and V2 use different action names to avoid conflicts: + * - V1: web_editor_ping, web_editor_toggle, etc. + * - V2: web_editor_ping_v2, web_editor_toggle_v2, etc. + */ async function ensureEditorInjected(tabId: number): Promise { + const scriptPath = USE_WEB_EDITOR_V2 ? V2_SCRIPT_PATH : V1_SCRIPT_PATH; + const logPrefix = USE_WEB_EDITOR_V2 ? '[WebEditorV2]' : '[WebEditor]'; + const actions = getActions(); + + // Try to ping existing instance using version-specific action try { - const pong: any = await chrome.tabs.sendMessage( + const pong: { status?: string; version?: number } = await chrome.tabs.sendMessage( tabId, - { action: 'web_editor_ping' } as any, - { frameId: 0 } as any, + { action: actions.PING }, + { frameId: 0 }, ); - if (pong?.status === 'pong') return; + + if (pong?.status === 'pong') { + // Already injected with correct version + return; + } } catch { - // Fallthrough to executeScript + // No existing instance, fallthrough to inject } + // Inject the script try { await chrome.scripting.executeScript({ target: { tabId }, - files: ['inject-scripts/web-editor.js'], + files: [scriptPath], world: 'ISOLATED', - } as any); + }); + console.log(`${logPrefix} Script injected successfully`); } catch (error) { - console.warn('[WebEditor] Failed to inject editor script:', error); + console.warn(`${logPrefix} Failed to inject editor script:`, error); } } async function toggleEditorInTab(tabId: number): Promise<{ active?: boolean }> { await ensureEditorInjected(tabId); + const logPrefix = USE_WEB_EDITOR_V2 ? '[WebEditorV2]' : '[WebEditor]'; + const actions = getActions(); + try { - const resp: any = await chrome.tabs.sendMessage( + const resp: { active?: boolean } = await chrome.tabs.sendMessage( tabId, - { action: 'web_editor_toggle' } as any, - { frameId: 0 } as any, + { action: actions.TOGGLE }, + { frameId: 0 }, ); return { active: typeof resp?.active === 'boolean' ? resp.active : undefined }; } catch (error) { - console.warn('[WebEditor] Failed to toggle editor in tab:', error); + console.warn(`${logPrefix} Failed to toggle editor in tab:`, error); return {}; } } diff --git a/app/chrome-extension/entrypoints/web-editor-v2.ts b/app/chrome-extension/entrypoints/web-editor-v2.ts new file mode 100644 index 0000000..4d1757d --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2.ts @@ -0,0 +1,47 @@ +/** + * Web Editor V2 - Inject Script Entry Point + * + * This is the main entry point for the visual editor, injected into web pages + * via chrome.scripting.executeScript from the background script. + * + * Architecture: + * - Uses WXT's defineUnlistedScript for TypeScript compilation + * - Exposes API on window.__MCP_WEB_EDITOR_V2__ + * - Communicates with background via chrome.runtime.onMessage + * + * Module structure: + * - web-editor-v2/constants.ts - Configuration values + * - web-editor-v2/utils/disposables.ts - Resource cleanup + * - web-editor-v2/ui/shadow-host.ts - Shadow DOM isolation + * - web-editor-v2/core/editor.ts - Main orchestrator + * - web-editor-v2/core/message-listener.ts - Background communication + * + * Build output: .output/chrome-mv3/web-editor-v2.js + */ + +import { WEB_EDITOR_V2_LOG_PREFIX } from './web-editor-v2/constants'; +import { createWebEditorV2 } from './web-editor-v2/core/editor'; +import { installMessageListener } from './web-editor-v2/core/message-listener'; + +export default defineUnlistedScript(() => { + // Phase 1: Only support top frame + // Phase 4 will add iframe support via content injection + if (window !== window.top) { + return; + } + + // Singleton guard: prevent multiple instances + if (window.__MCP_WEB_EDITOR_V2__) { + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Already installed, skipping initialization`); + return; + } + + // Create and expose the API + const api = createWebEditorV2(); + window.__MCP_WEB_EDITOR_V2__ = api; + + // Install message listener for background communication + installMessageListener(api); + + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Installed successfully`); +}); diff --git a/app/chrome-extension/entrypoints/web-editor-v2/constants.ts b/app/chrome-extension/entrypoints/web-editor-v2/constants.ts new file mode 100644 index 0000000..cccc9b2 --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/constants.ts @@ -0,0 +1,52 @@ +/** + * Web Editor V2 Constants + * + * Centralized configuration values for the visual editor. + * All magic strings/numbers should be defined here. + */ + +/** Editor version number */ +export const WEB_EDITOR_V2_VERSION = 2 as const; + +/** Log prefix for console messages */ +export const WEB_EDITOR_V2_LOG_PREFIX = '[WebEditorV2]' as const; + +// ============================================================================= +// DOM Element IDs +// ============================================================================= + +/** Shadow host element ID */ +export const WEB_EDITOR_V2_HOST_ID = '__mcp_web_editor_v2_host__'; + +/** Overlay container ID (for Canvas and visual feedback) */ +export const WEB_EDITOR_V2_OVERLAY_ID = '__mcp_web_editor_v2_overlay__'; + +/** UI container ID (for panels and controls) */ +export const WEB_EDITOR_V2_UI_ID = '__mcp_web_editor_v2_ui__'; + +// ============================================================================= +// Styling +// ============================================================================= + +/** Maximum z-index to ensure editor is always on top */ +export const WEB_EDITOR_V2_Z_INDEX = 2147483647; + +/** Default panel width */ +export const WEB_EDITOR_V2_PANEL_WIDTH = 320; + +// ============================================================================= +// Colors (Design System) +// ============================================================================= + +export const WEB_EDITOR_V2_COLORS = { + /** Hover highlight color */ + hover: '#3b82f6', // blue-500 + /** Selected element color */ + selected: '#22c55e', // green-500 + /** Selection box border */ + selectionBorder: '#6366f1', // indigo-500 + /** Drag ghost color */ + dragGhost: 'rgba(99, 102, 241, 0.3)', + /** Insertion line color */ + insertionLine: '#f59e0b', // amber-500 +} as const; diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/editor.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/editor.ts new file mode 100644 index 0000000..8a58c77 --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/editor.ts @@ -0,0 +1,376 @@ +/** + * Web Editor V2 Core + * + * Main orchestrator for the visual editor. + * Manages lifecycle of all subsystems (Shadow Host, Canvas, Interaction Engine, etc.) + */ + +import type { WebEditorState, WebEditorV2Api } from '@/common/web-editor-types'; +import { WEB_EDITOR_V2_VERSION, WEB_EDITOR_V2_LOG_PREFIX } from '../constants'; +import { mountShadowHost, type ShadowHostManager } from '../ui/shadow-host'; +import { createToolbar, type Toolbar } from '../ui/toolbar'; +import { createCanvasOverlay, type CanvasOverlay } from '../overlay/canvas-overlay'; +import { + createEventController, + type EventController, + type EventModifiers, +} from './event-controller'; +import { createPositionTracker, type PositionTracker, type TrackedRects } from './position-tracker'; +import { createSelectionEngine, type SelectionEngine } from '../selection/selection-engine'; +import { + createTransactionManager, + type TransactionManager, + type TransactionChangeEvent, +} from './transaction-manager'; +import { sendTransactionToAgent } from './payload-builder'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Internal editor state */ +interface EditorInternalState { + active: boolean; + shadowHost: ShadowHostManager | null; + canvasOverlay: CanvasOverlay | null; + eventController: EventController | null; + positionTracker: PositionTracker | null; + selectionEngine: SelectionEngine | null; + transactionManager: TransactionManager | null; + toolbar: Toolbar | null; + /** Currently hovered element (for hover highlight) */ + hoveredElement: Element | null; + /** Currently selected element (for selection highlight) */ + selectedElement: Element | null; +} + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Create the Web Editor V2 instance. + * + * This is the main factory function that creates the editor API. + * The returned object implements WebEditorV2Api and is exposed on window.__MCP_WEB_EDITOR_V2__ + */ +export function createWebEditorV2(): WebEditorV2Api { + const state: EditorInternalState = { + active: false, + shadowHost: null, + canvasOverlay: null, + eventController: null, + positionTracker: null, + selectionEngine: null, + transactionManager: null, + toolbar: null, + hoveredElement: null, + selectedElement: null, + }; + + // =========================================================================== + // Event Handlers (wired to EventController callbacks) + // =========================================================================== + + /** + * Handle hover state changes from EventController + */ + function handleHover(element: Element | null): void { + state.hoveredElement = element; + + // Delegate position tracking to PositionTracker + // Use forceUpdate to avoid extra rAF frame delay + if (state.positionTracker) { + state.positionTracker.setHoverElement(element); + state.positionTracker.forceUpdate(); + } + } + + /** + * Handle element selection from EventController + */ + function handleSelect(element: Element, modifiers: EventModifiers): void { + state.selectedElement = element; + state.hoveredElement = null; + + // Delegate position tracking to PositionTracker + // Clear hover, set selection, then force immediate update + if (state.positionTracker) { + state.positionTracker.setHoverElement(null); + state.positionTracker.setSelectionElement(element); + state.positionTracker.forceUpdate(); + } + + // Log selection with modifier info for debugging + const modInfo = modifiers.alt ? ' (Alt: drill-up)' : ''; + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Selected${modInfo}:`, element.tagName, element); + } + + /** + * Handle deselection (ESC key) from EventController + */ + function handleDeselect(): void { + state.selectedElement = null; + + // Clear selection tracking and force immediate update + if (state.positionTracker) { + state.positionTracker.setSelectionElement(null); + state.positionTracker.forceUpdate(); + } + + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Deselected`); + } + + /** + * Handle position updates from PositionTracker (scroll/resize sync) + */ + function handlePositionUpdate(rects: TrackedRects): void { + if (!state.canvasOverlay) return; + + // Update canvas overlay with new positions + state.canvasOverlay.setHoverRect(rects.hover); + state.canvasOverlay.setSelectionRect(rects.selection); + + // Force immediate render to avoid extra rAF delay + // This collapses the render to the same frame as position calculation + state.canvasOverlay.render(); + } + + /** + * Handle transaction changes from TransactionManager + */ + function handleTransactionChange(event: TransactionChangeEvent): void { + // Log transaction events for debugging + const { action, undoCount, redoCount } = event; + console.log( + `${WEB_EDITOR_V2_LOG_PREFIX} Transaction: ${action} (undo: ${undoCount}, redo: ${redoCount})`, + ); + + // Update toolbar UI with undo/redo counts + state.toolbar?.setHistory(undoCount, redoCount); + } + + /** + * Apply the latest transaction to Agent (Apply to Code) + */ + async function applyLatestTransaction(): Promise<{ requestId?: string }> { + const tm = state.transactionManager; + if (!tm) { + throw new Error('Transaction manager not ready'); + } + + const undoStack = tm.getUndoStack(); + const tx = undoStack.length > 0 ? undoStack[undoStack.length - 1] : null; + if (!tx) { + throw new Error('No changes to apply'); + } + + const resp = await sendTransactionToAgent(tx); + const r = resp as { success?: unknown; requestId?: unknown; error?: unknown } | null; + + if (r && r.success === true) { + return { requestId: typeof r.requestId === 'string' ? r.requestId : undefined }; + } + + const err = typeof r?.error === 'string' ? r.error : 'Agent request failed'; + throw new Error(err); + } + + /** + * Handle transaction apply errors + */ + function handleTransactionError(error: unknown): void { + console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Transaction apply error:`, error); + } + + /** + * Start the editor + */ + function start(): void { + if (state.active) { + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Already active`); + return; + } + + try { + // Mount Shadow DOM host + state.shadowHost = mountShadowHost({ + onRequestClose: () => stop(), + }); + + // Initialize Canvas Overlay + const elements = state.shadowHost.getElements(); + if (!elements?.overlayRoot) { + throw new Error('Shadow host overlayRoot not available'); + } + state.canvasOverlay = createCanvasOverlay({ + container: elements.overlayRoot, + }); + + // Initialize Selection Engine for intelligent element picking + state.selectionEngine = createSelectionEngine({ + isOverlayElement: state.shadowHost.isOverlayElement, + }); + + // Initialize Event Controller for interaction handling + // Wire up SelectionEngine's findBestTarget for intelligent selection (click only) + // Hover uses fast elementFromPoint for 60FPS performance + state.eventController = createEventController({ + isOverlayElement: state.shadowHost.isOverlayElement, + onHover: handleHover, + onSelect: handleSelect, + onDeselect: handleDeselect, + findTargetForSelect: (x, y, modifiers) => + state.selectionEngine?.findBestTarget(x, y, modifiers) ?? null, + }); + + // Initialize Position Tracker for scroll/resize synchronization + state.positionTracker = createPositionTracker({ + onPositionUpdate: handlePositionUpdate, + }); + + // Initialize Transaction Manager for undo/redo support + // Use isEventFromUi (not isOverlayElement) to properly check event source + state.transactionManager = createTransactionManager({ + enableKeyBindings: true, + isEventFromEditorUi: state.shadowHost.isEventFromUi, + onChange: handleTransactionChange, + onApplyError: handleTransactionError, + }); + + // Initialize Toolbar UI (must have uiRoot from shadow host) + if (!elements.uiRoot) { + throw new Error('Shadow host uiRoot not available'); + } + state.toolbar = createToolbar({ + container: elements.uiRoot, + dock: 'top', + onApply: applyLatestTransaction, + onUndo: () => state.transactionManager?.undo(), + onRedo: () => state.transactionManager?.redo(), + onRequestClose: () => stop(), + }); + + // Initialize toolbar history display + state.toolbar.setHistory( + state.transactionManager.getUndoStack().length, + state.transactionManager.getRedoStack().length, + ); + + state.active = true; + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Started`); + } catch (error) { + // Cleanup on failure (reverse order) + state.toolbar?.dispose(); + state.toolbar = null; + state.transactionManager?.dispose(); + state.transactionManager = null; + state.positionTracker?.dispose(); + state.positionTracker = null; + state.eventController?.dispose(); + state.eventController = null; + state.selectionEngine?.dispose(); + state.selectionEngine = null; + state.canvasOverlay?.dispose(); + state.canvasOverlay = null; + state.shadowHost?.dispose(); + state.shadowHost = null; + state.hoveredElement = null; + state.selectedElement = null; + state.active = false; + + console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Failed to start:`, error); + } + } + + /** + * Stop the editor + */ + function stop(): void { + if (!state.active) { + return; + } + + state.active = false; + + try { + // Cleanup in reverse order of initialization + + // Cleanup Toolbar UI + state.toolbar?.dispose(); + state.toolbar = null; + + // Cleanup Transaction Manager (clears history) + state.transactionManager?.dispose(); + state.transactionManager = null; + + // Cleanup Position Tracker (stops scroll/resize monitoring) + state.positionTracker?.dispose(); + state.positionTracker = null; + + // Cleanup Event Controller (stops event interception) + state.eventController?.dispose(); + state.eventController = null; + + // Cleanup Selection Engine + state.selectionEngine?.dispose(); + state.selectionEngine = null; + + // Cleanup Canvas Overlay + state.canvasOverlay?.dispose(); + state.canvasOverlay = null; + + // Cleanup Shadow DOM host + state.shadowHost?.dispose(); + state.shadowHost = null; + + // Clear element references + state.hoveredElement = null; + state.selectedElement = null; + + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Stopped`); + } catch (error) { + console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Error during cleanup:`, error); + + // Force cleanup + state.toolbar = null; + state.transactionManager = null; + state.positionTracker = null; + state.eventController = null; + state.selectionEngine = null; + state.canvasOverlay = null; + state.shadowHost = null; + state.hoveredElement = null; + state.selectedElement = null; + } + } + + /** + * Toggle the editor on/off + */ + function toggle(): boolean { + if (state.active) { + stop(); + } else { + start(); + } + return state.active; + } + + /** + * Get current editor state + */ + function getState(): WebEditorState { + return { + active: state.active, + version: WEB_EDITOR_V2_VERSION, + }; + } + + return { + start, + stop, + toggle, + getState, + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/event-controller.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/event-controller.ts new file mode 100644 index 0000000..8d3f07b --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/event-controller.ts @@ -0,0 +1,428 @@ +/** + * Event Controller + * + * Capture-phase event interceptor for Web Editor V2. + * + * Responsibilities: + * - Intercept document-level pointer/mouse/keyboard events in capture phase + * - Allow editor UI events (Shadow DOM) to pass through unmodified + * - Block page interactions while editor is active + * - Provide hover/selecting mode state machine + * - Trigger callbacks for element hover, selection, and deselection + * + * Performance considerations: + * - Uses rAF throttling for hover updates (elementFromPoint is expensive) + * - Supports both PointerEvents (modern) and MouseEvents (fallback) + * - Events are blocked via stopImmediatePropagation for complete isolation + */ + +import { Disposer } from '../utils/disposables'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Mode of the event controller state machine */ +export type EventControllerMode = 'hover' | 'selecting'; + +/** Keyboard modifiers state */ +export interface EventModifiers { + alt: boolean; + shift: boolean; + ctrl: boolean; + meta: boolean; +} + +/** Options for creating the event controller */ +export interface EventControllerOptions { + /** Check if a DOM node belongs to the editor overlay */ + isOverlayElement: (node: unknown) => boolean; + /** Called when hovering over an element (null when hovering over nothing) */ + onHover: (element: Element | null) => void; + /** Called when an element is selected via click */ + onSelect: (element: Element, modifiers: EventModifiers) => void; + /** Called when selection is cancelled (ESC key or mode change) */ + onDeselect: () => void; + /** + * Optional custom target finder for selection (click). + * If not provided, uses simple elementFromPoint. + * Only used for selection, not hover (for performance). + */ + findTargetForSelect?: (x: number, y: number, modifiers: EventModifiers) => Element | null; +} + +/** Event controller public interface */ +export interface EventController { + /** Get current interaction mode */ + getMode(): EventControllerMode; + /** Set interaction mode programmatically */ + setMode(mode: EventControllerMode): void; + /** Cleanup all event listeners */ + dispose(): void; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Common capture-phase listener options */ +const CAPTURE_OPTIONS: AddEventListenerOptions = { + capture: true, + passive: false, +}; + +/** Events to completely block on document (page interaction prevention) */ +const BLOCKED_POINTER_EVENTS = [ + 'pointerup', + 'pointercancel', + 'pointerover', + 'pointerout', + 'pointerenter', + 'pointerleave', +] as const; + +const BLOCKED_MOUSE_EVENTS = [ + 'mouseup', + 'click', + 'dblclick', + 'contextmenu', + 'auxclick', + 'mouseover', + 'mouseout', + 'mouseenter', + 'mouseleave', +] as const; + +const BLOCKED_KEYBOARD_EVENTS = ['keyup', 'keypress'] as const; + +const BLOCKED_TOUCH_EVENTS = ['touchstart', 'touchmove', 'touchend', 'touchcancel'] as const; + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Create an event controller for managing editor interactions. + * + * The controller operates in two modes: + * - `hover`: Mouse movement triggers onHover callbacks, click transitions to selecting + * - `selecting`: An element is selected, ESC key returns to hover mode + */ +export function createEventController(options: EventControllerOptions): EventController { + const { isOverlayElement, onHover, onSelect, onDeselect, findTargetForSelect } = options; + const disposer = new Disposer(); + + // Feature detection for PointerEvents + const hasPointerEvents = typeof PointerEvent !== 'undefined'; + + // ========================================================================== + // State + // ========================================================================== + + let mode: EventControllerMode = 'hover'; + let lastHoveredElement: Element | null = null; + + // Pointer position tracking for rAF-throttled hover updates + let hasPointerPosition = false; + let lastClientX = 0; + let lastClientY = 0; + + // Single rAF management (avoids Disposer array growth) + let hoverRafId: number | null = null; + + // ========================================================================== + // Helpers + // ========================================================================== + + /** + * Check if an event originated from the editor UI (Shadow DOM safe) + */ + function isEventFromEditorUi(event: Event): boolean { + try { + if (typeof event.composedPath === 'function') { + return event.composedPath().some((node) => isOverlayElement(node)); + } + } catch { + // Fallback to target check + } + return isOverlayElement(event.target); + } + + /** + * Block an event from reaching the page + */ + function blockPageEvent(event: Event): void { + if (event.cancelable) { + event.preventDefault(); + } + event.stopImmediatePropagation(); + event.stopPropagation(); + } + + /** Default modifiers (all false) */ + const defaultModifiers: EventModifiers = { + alt: false, + shift: false, + ctrl: false, + meta: false, + }; + + /** + * Extract modifiers from an event + */ + function extractModifiers(event: MouseEvent | KeyboardEvent): EventModifiers { + return { + alt: event.altKey, + shift: event.shiftKey, + ctrl: event.ctrlKey, + meta: event.metaKey, + }; + } + + /** + * Get the topmost element at a viewport coordinate (fast, for hover). + * Uses simple elementFromPoint to maintain 60FPS hover performance. + */ + function getTargetElementAtFast(clientX: number, clientY: number): Element | null { + if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return null; + } + + const element = document.elementFromPoint(clientX, clientY); + if (!element) return null; + + // Skip if element is part of the editor overlay + if (isOverlayElement(element)) return null; + + return element; + } + + /** + * Get the best target element for selection (can be slower, uses intelligent picking). + * Uses custom findTargetForSelect if provided, otherwise falls back to fast method. + */ + function getTargetElementForSelection( + clientX: number, + clientY: number, + modifiers: EventModifiers, + ): Element | null { + if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return null; + } + + // Use intelligent target finder if provided (e.g., SelectionEngine) + if (findTargetForSelect) { + const target = findTargetForSelect(clientX, clientY, modifiers); + // Defensive check: ensure result is not an overlay element + if (target && isOverlayElement(target)) return null; + return target; + } + + // Fallback: simple elementFromPoint + return getTargetElementAtFast(clientX, clientY); + } + + // ========================================================================== + // Hover Logic (rAF throttled) + // ========================================================================== + + /** + * Cancel any pending hover rAF + */ + function cancelHoverRaf(): void { + if (hoverRafId !== null) { + cancelAnimationFrame(hoverRafId); + hoverRafId = null; + } + } + // Register cleanup for disposal + disposer.add(cancelHoverRaf); + + /** + * Commit the hover update by finding element at current pointer position + */ + function commitHoverUpdate(forceUpdate = false): void { + hoverRafId = null; + + if (disposer.isDisposed) return; + if (mode !== 'hover') return; + if (!hasPointerPosition) return; + + // Use fast method for hover (60FPS performance) + const nextElement = getTargetElementAtFast(lastClientX, lastClientY); + + // Skip if same element (pointer identity check), unless forced + if (!forceUpdate && nextElement === lastHoveredElement) return; + + lastHoveredElement = nextElement; + onHover(nextElement); + } + + /** + * Schedule a hover update on the next animation frame + */ + function scheduleHoverUpdate(forceUpdate = false): void { + // If already pending, don't schedule another + if (hoverRafId !== null) return; + if (disposer.isDisposed) return; + + // Use rAF to throttle elementFromPoint calls to once per frame + // This prevents performance degradation from high-frequency pointer events + hoverRafId = requestAnimationFrame(() => { + commitHoverUpdate(forceUpdate); + }); + } + + // ========================================================================== + // Mode Management + // ========================================================================== + + /** + * Set the interaction mode + */ + function setMode(nextMode: EventControllerMode): void { + if (disposer.isDisposed) return; + if (mode === nextMode) return; + + const prevMode = mode; + mode = nextMode; + + // Handle transitions + if (prevMode === 'hover' && nextMode === 'selecting') { + // Entering selection mode - cancel pending hover, reset tracked element + cancelHoverRaf(); + lastHoveredElement = null; + } else if (prevMode === 'selecting' && nextMode === 'hover') { + // Exiting selection - notify and force resume hover tracking + // Reset lastHoveredElement to force onHover callback even if pointer is on same element + lastHoveredElement = null; + onDeselect(); + if (hasPointerPosition) { + // Force update to re-highlight element under pointer + scheduleHoverUpdate(true); + } + } + } + + // ========================================================================== + // Event Handlers + // ========================================================================== + + /** + * Handle pointer/mouse move for hover tracking + */ + function handlePointerMove(event: PointerEvent | MouseEvent): void { + // If event is from editor UI, clear hover highlight and return + if (isEventFromEditorUi(event)) { + if (mode === 'hover' && lastHoveredElement !== null) { + lastHoveredElement = null; + onHover(null); + } + return; + } + blockPageEvent(event); + + // Update tracked position + lastClientX = event.clientX; + lastClientY = event.clientY; + hasPointerPosition = true; + + // Only process hover in hover mode + if (mode !== 'hover') return; + scheduleHoverUpdate(); + } + + /** + * Handle pointer/mouse down for element selection + */ + function handlePointerDown(event: PointerEvent | MouseEvent): void { + if (isEventFromEditorUi(event)) return; + blockPageEvent(event); + + // Update tracked position + lastClientX = event.clientX; + lastClientY = event.clientY; + hasPointerPosition = true; + + // Only process in hover mode, left-click only + if (mode !== 'hover') return; + if (event.button !== 0) return; + + // Extract modifiers for intelligent selection (e.g., Alt for drill-up) + const modifiers = extractModifiers(event); + // Use intelligent selection for click (can afford more computation) + const target = getTargetElementForSelection(event.clientX, event.clientY, modifiers); + if (!target) return; + + // Transition to selecting mode + setMode('selecting'); + onSelect(target, modifiers); + } + + /** + * Handle keydown for ESC cancellation + */ + function handleKeyDown(event: KeyboardEvent): void { + if (isEventFromEditorUi(event)) return; + blockPageEvent(event); + + // ESC key cancels selection + if (event.key === 'Escape' && mode === 'selecting') { + setMode('hover'); + } + } + + /** + * Generic blocker for events that should never reach the page + */ + function handleBlockedEvent(event: Event): void { + if (isEventFromEditorUi(event)) return; + blockPageEvent(event); + } + + // ========================================================================== + // Event Registration + // ========================================================================== + + // Register pointer events (modern browsers) + if (hasPointerEvents) { + disposer.listen(document, 'pointermove', handlePointerMove, CAPTURE_OPTIONS); + disposer.listen(document, 'pointerdown', handlePointerDown, CAPTURE_OPTIONS); + + for (const eventType of BLOCKED_POINTER_EVENTS) { + disposer.listen(document, eventType, handleBlockedEvent, CAPTURE_OPTIONS); + } + } + + // Register mouse events (fallback for older browsers, or when pointer events are unavailable) + // Note: On modern browsers with PointerEvents, mouse events still fire after pointer events, + // so we always register them to ensure complete blocking + disposer.listen(document, 'mousemove', handlePointerMove, CAPTURE_OPTIONS); + disposer.listen(document, 'mousedown', handlePointerDown, CAPTURE_OPTIONS); + + for (const eventType of BLOCKED_MOUSE_EVENTS) { + disposer.listen(document, eventType, handleBlockedEvent, CAPTURE_OPTIONS); + } + + // Register keyboard events + disposer.listen(document, 'keydown', handleKeyDown, CAPTURE_OPTIONS); + + for (const eventType of BLOCKED_KEYBOARD_EVENTS) { + disposer.listen(document, eventType, handleBlockedEvent, CAPTURE_OPTIONS); + } + + // Register touch events (prevent touch interactions on mobile) + for (const eventType of BLOCKED_TOUCH_EVENTS) { + disposer.listen(document, eventType, handleBlockedEvent, CAPTURE_OPTIONS); + } + + // ========================================================================== + // Public API + // ========================================================================== + + return { + getMode: () => mode, + setMode, + dispose: () => disposer.dispose(), + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/locator.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/locator.ts new file mode 100644 index 0000000..f0f6673 --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/locator.ts @@ -0,0 +1,544 @@ +/** + * Element Locator Utilities + * + * Generates and resolves CSS-based element locators for the Transaction System. + * + * Design goals: + * - Generate stable, unique CSS selectors for DOM elements + * - Support Shadow DOM boundaries with host chain traversal + * - Provide fallback strategies when primary selector fails + * - Compute structural fingerprints for fuzzy matching + */ + +import type { ElementLocator } from '@/common/web-editor-types'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Options for CSS selector generation */ +export interface SelectorGenerationOptions { + /** Root node for uniqueness checking (defaults to element's root) */ + root?: Document | ShadowRoot; + /** Maximum number of selector candidates to generate */ + maxCandidates?: number; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Maximum candidate selectors to generate */ +const DEFAULT_MAX_CANDIDATES = 5; + +/** Maximum text length for fingerprint */ +const FINGERPRINT_TEXT_MAX_LENGTH = 32; + +/** Maximum classes to include in fingerprint */ +const FINGERPRINT_MAX_CLASSES = 8; + +/** Priority ordered data attributes for unique identification */ +const UNIQUE_DATA_ATTRS = [ + 'data-testid', + 'data-test-id', + 'data-test', + 'data-qa', + 'data-cy', + 'name', + 'title', + 'alt', +] as const; + +/** Maximum class combinations to try for uniqueness */ +const MAX_CLASS_COMBO_DEPTH = 3; + +// ============================================================================= +// CSS Escape Utility +// ============================================================================= + +/** + * Escape a string for use in CSS selector. + * Uses native CSS.escape if available, otherwise a spec-compliant polyfill. + */ +function cssEscape(value: string): string { + // Try native CSS.escape + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value); + } + + // Polyfill based on CSSOM spec + const str = String(value); + const len = str.length; + if (len === 0) return ''; + + let result = ''; + const firstCodeUnit = str.charCodeAt(0); + + for (let i = 0; i < len; i++) { + const codeUnit = str.charCodeAt(i); + + // Null character -> replacement character + if (codeUnit === 0x0000) { + result += '\uFFFD'; + continue; + } + + // Control characters and special numeric positions + if ( + (codeUnit >= 0x0001 && codeUnit <= 0x001f) || + codeUnit === 0x007f || + (i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (i === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d) + ) { + result += `\\${codeUnit.toString(16)} `; + continue; + } + + // Single hyphen at start + if (i === 0 && len === 1 && codeUnit === 0x002d) { + result += `\\${str.charAt(i)}`; + continue; + } + + // Safe ASCII characters (alphanumeric, hyphen, underscore) + const isAsciiAlnum = + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || // 0-9 + (codeUnit >= 0x0041 && codeUnit <= 0x005a) || // A-Z + (codeUnit >= 0x0061 && codeUnit <= 0x007a); // a-z + const isSafe = isAsciiAlnum || codeUnit === 0x002d || codeUnit === 0x005f; + + if (isSafe) { + result += str.charAt(i); + } else { + result += `\\${str.charAt(i)}`; + } + } + + return result; +} + +// ============================================================================= +// Query Helpers +// ============================================================================= + +/** + * Get the query root for an element (Document or ShadowRoot) + */ +function getQueryRoot(element: Element): Document | ShadowRoot { + const root = element.getRootNode?.(); + return root instanceof ShadowRoot ? root : document; +} + +/** + * Safely execute querySelector, returning null on invalid selectors + */ +function safeQuerySelector(root: ParentNode, selector: string): Element | null { + try { + return root.querySelector(selector); + } catch { + return null; + } +} + +/** + * Check if a selector matches exactly one element in the root + */ +function isUnique(root: ParentNode, selector: string): boolean { + try { + return root.querySelectorAll(selector).length === 1; + } catch { + return false; + } +} + +// ============================================================================= +// Selector Generation Strategies +// ============================================================================= + +/** + * Try to build a unique ID-based selector + */ +function tryIdSelector(element: Element, root: ParentNode): string | null { + const id = element.id?.trim(); + if (!id) return null; + + const selector = `#${cssEscape(id)}`; + return isUnique(root, selector) ? selector : null; +} + +/** + * Try to build a unique data-attribute selector + */ +function tryDataAttrSelector(element: Element, root: ParentNode): string | null { + const tag = element.tagName.toLowerCase(); + + for (const attr of UNIQUE_DATA_ATTRS) { + const value = element.getAttribute(attr)?.trim(); + if (!value) continue; + + // Try attribute alone + const attrOnly = `[${attr}="${cssEscape(value)}"]`; + if (isUnique(root, attrOnly)) return attrOnly; + + // Try with tag prefix + const withTag = `${tag}${attrOnly}`; + if (isUnique(root, withTag)) return withTag; + } + + return null; +} + +/** + * Try to build a unique class-based selector + */ +function tryClassSelector(element: Element, root: ParentNode): string | null { + const tag = element.tagName.toLowerCase(); + const classes = Array.from(element.classList).filter( + (c) => c && /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(c), + ); + + if (classes.length === 0) return null; + + // Try single class + for (const cls of classes) { + const sel = `.${cssEscape(cls)}`; + if (isUnique(root, sel)) return sel; + } + + // Try tag + single class + for (const cls of classes) { + const sel = `${tag}.${cssEscape(cls)}`; + if (isUnique(root, sel)) return sel; + } + + // Try class combinations (up to 3) + const limit = Math.min(classes.length, MAX_CLASS_COMBO_DEPTH); + for (let i = 0; i < limit; i++) { + for (let j = i + 1; j < limit; j++) { + const sel = `.${cssEscape(classes[i])}.${cssEscape(classes[j])}`; + if (isUnique(root, sel)) return sel; + } + } + + return null; +} + +/** + * Build a structural path selector using nth-of-type + */ +function buildPathSelector(element: Element, root: Document | ShadowRoot): string { + const segments: string[] = []; + let current: Element | null = element; + + // Determine stop condition based on root type + const isDocument = root instanceof Document; + + while (current && current.nodeType === Node.ELEMENT_NODE) { + const tag = current.tagName.toLowerCase(); + + // Stop at body for document, or at shadow root boundary + if (isDocument && tag === 'body') break; + + let selector = tag; + + // Find parent context + const parent = current.parentElement; + const parentNode = current.parentNode; + + // Get siblings from appropriate parent + let siblings: Element[]; + if (parent) { + siblings = Array.from(parent.children); + } else if (parentNode instanceof ShadowRoot || parentNode instanceof Document) { + siblings = Array.from(parentNode.children); + } else { + siblings = []; + } + + // Add nth-of-type if there are siblings with same tag + const sameTagSiblings = siblings.filter((s) => s.tagName === current!.tagName); + if (sameTagSiblings.length > 1) { + const index = sameTagSiblings.indexOf(current) + 1; + selector += `:nth-of-type(${index})`; + } + + segments.unshift(selector); + current = parent; + + // Stop if we've reached the root's direct children + if (!parent && parentNode === root) break; + } + + const path = segments.join(' > '); + return isDocument ? `body > ${path}` : path || '*'; +} + +// ============================================================================= +// Shadow DOM Utilities +// ============================================================================= + +/** + * Get selector chain for shadow host ancestors (from outer to inner) + */ +export function getShadowHostChain(element: Element): string[] | undefined { + const chain: string[] = []; + let current: Element = element; + + while (true) { + const root = current.getRootNode?.(); + if (!(root instanceof ShadowRoot)) break; + + const host = root.host; + if (!(host instanceof Element)) break; + + const hostRoot = getQueryRoot(host); + const hostSelector = generateCssSelector(host, { root: hostRoot }); + if (!hostSelector) break; + + chain.unshift(hostSelector); + current = host; + } + + return chain.length > 0 ? chain : undefined; +} + +// ============================================================================= +// Fingerprint Generation +// ============================================================================= + +/** + * Normalize text content for fingerprinting + */ +function normalizeText(text: string, maxLength: number): string { + return text.replace(/\s+/g, ' ').trim().slice(0, maxLength); +} + +/** + * Compute a structural fingerprint for fuzzy element matching + */ +export function computeFingerprint(element: Element): string { + const parts: string[] = []; + + // Tag name + const tag = element.tagName?.toLowerCase() ?? 'unknown'; + parts.push(tag); + + // ID if present + const id = element.id?.trim(); + if (id) { + parts.push(`id=${id}`); + } + + // Class names (limited) + const classes = Array.from(element.classList).slice(0, FINGERPRINT_MAX_CLASSES); + if (classes.length > 0) { + parts.push(`class=${classes.join('.')}`); + } + + // Text content hint + const text = normalizeText(element.textContent ?? '', FINGERPRINT_TEXT_MAX_LENGTH); + if (text) { + parts.push(`text=${text}`); + } + + return parts.join('|'); +} + +// ============================================================================= +// DOM Path Computation +// ============================================================================= + +/** + * Compute the DOM tree path as child indices from root + */ +export function computeDomPath(element: Element): number[] { + const path: number[] = []; + let current: Element | null = element; + + while (current) { + const parent = current.parentElement; + + if (parent) { + const siblings = Array.from(parent.children); + const index = siblings.indexOf(current); + if (index >= 0) path.unshift(index); + current = parent; + continue; + } + + // Check for shadow root or document as parent + const parentNode = current.parentNode; + if (parentNode instanceof ShadowRoot || parentNode instanceof Document) { + const children = Array.from(parentNode.children); + const index = children.indexOf(current); + if (index >= 0) path.unshift(index); + } + + break; + } + + return path; +} + +// ============================================================================= +// Public API - Selector Generation +// ============================================================================= + +/** + * Generate multiple CSS selector candidates for an element. + * Candidates are ordered by preference: ID > data-attr > class > path + */ +export function generateSelectorCandidates( + element: Element, + options: SelectorGenerationOptions = {}, +): string[] { + const root = options.root ?? getQueryRoot(element); + const maxCandidates = Math.max(1, options.maxCandidates ?? DEFAULT_MAX_CANDIDATES); + + const candidates: string[] = []; + + const push = (selector: string | null): void => { + if (!selector) return; + const s = selector.trim(); + if (!s || candidates.includes(s)) return; + candidates.push(s); + }; + + // Try strategies in order of preference + push(tryIdSelector(element, root)); + push(tryDataAttrSelector(element, root)); + push(tryClassSelector(element, root)); + push(buildPathSelector(element, root)); + + return candidates.slice(0, maxCandidates); +} + +/** + * Generate a single best CSS selector for an element + */ +export function generateCssSelector( + element: Element, + options: SelectorGenerationOptions = {}, +): string { + return generateSelectorCandidates(element, options)[0] ?? ''; +} + +// ============================================================================= +// Public API - Locator Creation & Resolution +// ============================================================================= + +/** + * Create a complete ElementLocator for an element. + * The locator contains multiple strategies for re-identification. + */ +export function createElementLocator(element: Element): ElementLocator { + const root = getQueryRoot(element); + + return { + selectors: generateSelectorCandidates(element, { root, maxCandidates: DEFAULT_MAX_CANDIDATES }), + fingerprint: computeFingerprint(element), + path: computeDomPath(element), + shadowHostChain: getShadowHostChain(element), + }; +} + +/** + * Safely check if a selector matches exactly one element + */ +function isSelectorUnique(root: ParentNode, selector: string): boolean { + try { + return root.querySelectorAll(selector).length === 1; + } catch { + return false; + } +} + +/** + * Verify element matches the stored fingerprint + */ +function verifyFingerprint(element: Element, fingerprint: string): boolean { + const currentFingerprint = computeFingerprint(element); + // Simple check: verify tag and id match at minimum + const storedParts = fingerprint.split('|'); + const currentParts = currentFingerprint.split('|'); + + // At minimum, tag should match + if (storedParts[0] !== currentParts[0]) return false; + + // If stored has id, current should have same id + const storedId = storedParts.find((p) => p.startsWith('id=')); + const currentId = currentParts.find((p) => p.startsWith('id=')); + if (storedId && storedId !== currentId) return false; + + return true; +} + +/** + * Resolve an ElementLocator to a DOM element. + * Traverses Shadow DOM boundaries and tries multiple selector candidates. + * Includes uniqueness verification to avoid misidentification. + */ +export function locateElement( + locator: ElementLocator, + rootDocument: Document = document, +): Element | null { + let doc: Document = rootDocument; + + // Traverse iframe chain (Phase 4 - not implemented yet) + if (locator.frameChain?.length) { + for (const frameSelector of locator.frameChain) { + const frame = safeQuerySelector(doc, frameSelector); + if (!(frame instanceof HTMLIFrameElement)) return null; + const contentDoc = frame.contentDocument; + if (!contentDoc) return null; + doc = contentDoc; + } + } + + // Start with document as query root + let queryRoot: Document | ShadowRoot = doc; + + // Traverse Shadow DOM host chain + if (locator.shadowHostChain?.length) { + for (const hostSelector of locator.shadowHostChain) { + // Verify host selector is unique + if (!isSelectorUnique(queryRoot, hostSelector)) return null; + + const host = safeQuerySelector(queryRoot, hostSelector); + if (!host) return null; + + const shadowRoot = (host as HTMLElement).shadowRoot; + if (!shadowRoot) return null; + + queryRoot = shadowRoot; + } + } + + // Try each selector candidate with uniqueness and fingerprint verification + for (const selector of locator.selectors) { + // Check if selector still matches exactly one element + if (!isSelectorUnique(queryRoot, selector)) continue; + + const element = safeQuerySelector(queryRoot, selector); + if (!element) continue; + + // Verify fingerprint matches to catch "same selector, different element" cases + if (locator.fingerprint && !verifyFingerprint(element, locator.fingerprint)) { + continue; + } + + return element; + } + + return null; +} + +/** + * Generate a unique key for an ElementLocator (for comparison/caching) + */ +export function locatorKey(locator: ElementLocator): string { + const selectors = locator.selectors.join('|'); + const shadow = locator.shadowHostChain?.join('>') ?? ''; + const frame = locator.frameChain?.join('>') ?? ''; + return `frame:${frame}|shadow:${shadow}|sel:${selectors}`; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/message-listener.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/message-listener.ts new file mode 100644 index 0000000..a37e55b --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/message-listener.ts @@ -0,0 +1,117 @@ +/** + * Web Editor V2 Message Listener + * + * Handles chrome.runtime.onMessage communication with the background script. + * Uses versioned action names (suffix _v2) to avoid conflicts with V1. + */ + +import type { + WebEditorV2Api, + WebEditorV2Request, + WebEditorV2PingResponse, + WebEditorV2ToggleResponse, + WebEditorV2StartResponse, + WebEditorV2StopResponse, +} from '@/common/web-editor-types'; +import { WEB_EDITOR_V2_ACTIONS } from '@/common/web-editor-types'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Function to remove the message listener */ +export type RemoveMessageListener = () => void; + +/** All possible V2 response types */ +type WebEditorV2Response = + | WebEditorV2PingResponse + | WebEditorV2ToggleResponse + | WebEditorV2StartResponse + | WebEditorV2StopResponse; + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Type guard to check if a request is a V2 editor request + */ +function isV2Request(request: unknown): request is WebEditorV2Request { + if (!request || typeof request !== 'object') return false; + + const action = (request as { action?: unknown }).action; + return ( + action === WEB_EDITOR_V2_ACTIONS.PING || + action === WEB_EDITOR_V2_ACTIONS.TOGGLE || + action === WEB_EDITOR_V2_ACTIONS.START || + action === WEB_EDITOR_V2_ACTIONS.STOP + ); +} + +/** + * Install the message listener for background communication. + * Returns a function to remove the listener. + * + * @param api The WebEditorV2Api instance to delegate commands to + * @returns Function to remove the listener + */ +export function installMessageListener(api: WebEditorV2Api): RemoveMessageListener { + const listener = ( + request: unknown, + _sender: chrome.runtime.MessageSender, + sendResponse: (response: WebEditorV2Response) => void, + ): boolean => { + // Only handle V2 requests + if (!isV2Request(request)) { + return false; + } + + switch (request.action) { + case WEB_EDITOR_V2_ACTIONS.PING: { + const response: WebEditorV2PingResponse = { + status: 'pong', + active: api.getState().active, + version: 2, + }; + sendResponse(response); + return false; // Synchronous response + } + + case WEB_EDITOR_V2_ACTIONS.TOGGLE: { + const response: WebEditorV2ToggleResponse = { + active: api.toggle(), + }; + sendResponse(response); + return false; + } + + case WEB_EDITOR_V2_ACTIONS.START: { + api.start(); + const response: WebEditorV2StartResponse = { + active: true, + }; + sendResponse(response); + return false; + } + + case WEB_EDITOR_V2_ACTIONS.STOP: { + api.stop(); + const response: WebEditorV2StopResponse = { + active: false, + }; + sendResponse(response); + return false; + } + + default: + // Should never reach here due to type guard + return false; + } + }; + + chrome.runtime.onMessage.addListener(listener); + + return () => { + chrome.runtime.onMessage.removeListener(listener); + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/payload-builder.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/payload-builder.ts new file mode 100644 index 0000000..5fcc30b --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/payload-builder.ts @@ -0,0 +1,533 @@ +/** + * Payload Builder (Phase 1.8) + * + * Builds "Apply to Code" payload from Transactions for sending to the Agent. + * + * Design goals: + * - Reuse existing BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY pipeline + * - Extract React/Vue component debug info when available + * - Build comprehensive style diff descriptions + * - Detect tech stack (Tailwind, React, Vue) from DOM hints + */ + +import type { DebugSource, ElementLocator, Transaction } from '@/common/web-editor-types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; +import { locateElement } from './locator'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Instruction type for Apply payload */ +export type ApplyInstructionType = 'update_text' | 'update_style'; + +/** Element fingerprint for identification */ +export interface ElementFingerprint { + tag: string; + id?: string; + classes: string[]; + text?: string; +} + +/** Style change instruction */ +export interface ApplyInstruction { + type: ApplyInstructionType; + description: string; + text?: string; + style?: Record; +} + +/** Complete payload sent to background/Agent */ +export interface ApplyPayload { + pageUrl: string; + targetFile?: string; + fingerprint: ElementFingerprint; + techStackHint?: string[]; + instruction: ApplyInstruction; + + // V2 extended fields (best-effort, optional) + locator?: ElementLocator; + selectorCandidates?: string[]; + debugSource?: DebugSource; + operation?: { + type: 'update_style'; + before: Record; + after: Record; + removed: string[]; + }; +} + +/** Options for building payload */ +export interface BuildPayloadOptions { + pageUrl?: string; + /** Pre-resolved element to avoid re-locating */ + element?: Element | null; + /** Max selectors to include in description */ + maxSelectorsInDescription?: number; +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Safely access object as record + */ +function asRecord(value: unknown): Record | null { + if (value && typeof value === 'object') { + return value as Record; + } + return null; +} + +/** + * Read optional string value + */ +function readString(value: unknown): string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || undefined; + } + return undefined; +} + +/** + * Read optional number value + */ +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** + * Normalize text snippet for display + */ +function normalizeText(text: string, maxLength: number): string { + return String(text ?? '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength); +} + +// ============================================================================= +// Fingerprint Generation +// ============================================================================= + +/** + * Build fingerprint from DOM element + */ +function buildFingerprintFromElement(element: Element): ElementFingerprint { + const tag = element.tagName?.toLowerCase() ?? 'unknown'; + const id = readString((element as HTMLElement).id); + const classes = Array.from(element.classList ?? []).slice(0, 24); + const text = readString(normalizeText(element.textContent ?? '', 96)); + + return { tag, id, classes, text }; +} + +/** + * Build fingerprint from locator (fallback when element not available) + */ +function buildFingerprintFromLocator(locator: ElementLocator): ElementFingerprint { + const raw = readString(locator.fingerprint) ?? ''; + const parts = raw.split('|').filter(Boolean); + + const tag = parts[0] || 'unknown'; + let id: string | undefined; + let classes: string[] = []; + let text: string | undefined; + + for (const part of parts.slice(1)) { + if (part.startsWith('id=')) { + id = readString(part.slice(3)); + } else if (part.startsWith('class=')) { + classes = part.slice(6).split('.').filter(Boolean); + } else if (part.startsWith('text=')) { + text = readString(part.slice(5)); + } + } + + return { tag, id, classes, text }; +} + +// ============================================================================= +// Tech Stack Detection +// ============================================================================= + +/** Tailwind class patterns */ +const TAILWIND_PATTERNS = [ + /^bg-/, + /^text-/, + /^p[trblxy]?-/, + /^m[trblxy]?-/, + /^flex$/, + /^grid$/, + /^items-/, + /^justify-/, + /^gap-/, + /^rounded/, + /^shadow/, + /^border/, + /^w-/, + /^h-/, +]; + +/** + * Detect if element uses Tailwind CSS + */ +function detectTailwind(classes: string[]): boolean { + return classes.some((cls) => TAILWIND_PATTERNS.some((p) => p.test(cls))); +} + +/** + * Read component name from function/object + */ +function readComponentName(value: unknown): string | undefined { + if (!value) return undefined; + + if (typeof value === 'function') { + const fn = value as { displayName?: unknown; name?: unknown }; + return readString(fn.displayName) ?? readString(fn.name); + } + + const rec = asRecord(value); + if (rec) { + return readString(rec.displayName) ?? readString(rec.name); + } + + return undefined; +} + +// ============================================================================= +// React Debug Source Extraction +// ============================================================================= + +/** + * Extract debug source from React Fiber + */ +function extractReactDebugSource(fiber: unknown): DebugSource | null { + let current = fiber; + + for (let i = 0; i < 40 && current; i++) { + const rec = asRecord(current); + if (!rec) break; + + // Check _debugSource + const src = asRecord(rec._debugSource); + const file = readString(src?.fileName); + if (file) { + const componentName = readComponentName(rec.elementType) ?? readComponentName(rec.type); + return { + file, + line: readNumber(src?.lineNumber), + column: readNumber(src?.columnNumber), + componentName, + }; + } + + // Check owner's debug source + const owner = asRecord(rec._debugOwner); + const ownerSrc = asRecord(owner?._debugSource); + const ownerFile = readString(ownerSrc?.fileName); + if (ownerFile) { + const componentName = readComponentName(owner?.elementType) ?? readComponentName(owner?.type); + return { + file: ownerFile, + line: readNumber(ownerSrc?.lineNumber), + column: readNumber(ownerSrc?.columnNumber), + componentName, + }; + } + + current = rec.return; + } + + return null; +} + +/** + * Find React debug source from element + */ +function findReactDebugSource(element: Element): DebugSource | null { + try { + let node: Element | null = element; + + for (let depth = 0; depth < 15 && node; depth++) { + const rec = node as unknown as Record; + + for (const key of Object.keys(rec)) { + if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) { + const source = extractReactDebugSource(rec[key]); + if (source) return source; + } + } + + node = node.parentElement; + } + } catch { + // Best-effort only + } + + return null; +} + +// ============================================================================= +// Vue Debug Source Extraction +// ============================================================================= + +/** + * Find Vue debug source from element + */ +function findVueDebugSource(element: Element): DebugSource | null { + try { + let node: Element | null = element; + + for (let depth = 0; depth < 15 && node; depth++) { + const rec = node as unknown as Record; + const inst = asRecord(rec.__vueParentComponent); + const typeRec = asRecord(inst?.type); + const file = readString(typeRec?.__file); + + if (file) { + return { + file, + componentName: readString(typeRec?.name), + }; + } + + node = node.parentElement; + } + } catch { + // Best-effort only + } + + return null; +} + +// ============================================================================= +// Component Hints Resolution +// ============================================================================= + +interface ComponentHints { + targetFile?: string; + debugSource?: DebugSource; + techStackHint?: string[]; +} + +/** + * Resolve component hints from element + */ +function resolveComponentHints(element: Element): ComponentHints { + let targetFile: string | undefined; + let debugSource: DebugSource | undefined; + const hints = new Set(); + + let node: Element | null = element; + + for (let depth = 0; depth < 20 && node; depth++) { + // Try React + const react = findReactDebugSource(node); + if (react?.file) { + hints.add('React'); + if (!targetFile && !react.file.includes('node_modules')) { + targetFile = react.file; + debugSource = react; + break; + } + } + + // Try Vue + const vue = findVueDebugSource(node); + if (vue?.file) { + hints.add('Vue'); + if (!targetFile && !vue.file.includes('node_modules')) { + targetFile = vue.file; + debugSource = vue; + break; + } + } + + node = node.parentElement; + } + + // Check for Tailwind + const classes = Array.from(element.classList ?? []).slice(0, 128); + if (detectTailwind(classes)) { + hints.add('Tailwind'); + } + + return { + targetFile, + debugSource, + techStackHint: hints.size > 0 ? Array.from(hints) : undefined, + }; +} + +// ============================================================================= +// Style Diff Computation +// ============================================================================= + +interface StyleDiff { + before: Record; + after: Record; + set: Record; + removed: string[]; +} + +/** + * Compute style diff from transaction + */ +function computeStyleDiff(tx: Transaction): StyleDiff | null { + const beforeRaw = tx.before.styles ?? {}; + const afterRaw = tx.after.styles ?? {}; + + const keys = new Set([...Object.keys(beforeRaw), ...Object.keys(afterRaw)]); + if (keys.size === 0) return null; + + const before: Record = {}; + const after: Record = {}; + const set: Record = {}; + const removed: string[] = []; + + for (const key of keys) { + const b = String(beforeRaw[key] ?? '').trim(); + const a = String(afterRaw[key] ?? '').trim(); + + if (b === a) continue; + + before[key] = b; + after[key] = a; + + if (a) { + set[key] = a; + } else { + removed.push(key); + } + } + + if (Object.keys(before).length === 0 && Object.keys(after).length === 0) { + return null; + } + + return { before, after, set, removed }; +} + +/** + * Build human-readable style description + */ +function buildStyleDescription( + locator: ElementLocator, + diff: StyleDiff, + maxSelectors: number, +): string { + const selectors = (locator.selectors ?? []).filter(Boolean); + const selectorPreview = selectors.slice(0, maxSelectors).join(' | '); + + const changes: string[] = []; + for (const [prop, nextVal] of Object.entries(diff.after)) { + const prevVal = diff.before[prop] ?? ''; + if (nextVal) { + changes.push(`${prop}: "${prevVal}" -> "${nextVal}"`); + } else { + changes.push(`${prop}: remove (was "${prevVal}")`); + } + } + + const selPart = selectorPreview ? `selectors: ${selectorPreview}` : 'selectors: (unavailable)'; + return `Update element styles (${selPart}). ${changes.join('; ')}`; +} + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Build Apply payload from a Transaction + */ +export function buildApplyPayload( + tx: Transaction, + options: BuildPayloadOptions = {}, +): ApplyPayload | null { + // Only support style transactions for now + if (tx.type !== 'style') return null; + + const pageUrl = readString(options.pageUrl ?? globalThis.location?.href) ?? ''; + if (!pageUrl) return null; + + const locator = tx.targetLocator; + const diff = computeStyleDiff(tx); + if (!diff) return null; + + // Resolve element + const element = + options.element !== undefined ? options.element : locateElement(locator, document); + + // Build fingerprint + const fingerprint = element + ? buildFingerprintFromElement(element) + : buildFingerprintFromLocator(locator); + + // Resolve component hints + const hints = element ? resolveComponentHints(element) : {}; + + // Build description + const maxSelectors = Math.max(0, options.maxSelectorsInDescription ?? 3); + const description = buildStyleDescription(locator, diff, maxSelectors); + + // Build payload + const payload: ApplyPayload = { + pageUrl, + targetFile: hints.targetFile, + fingerprint, + techStackHint: hints.techStackHint, + instruction: { + type: 'update_style', + description, + style: Object.keys(diff.set).length > 0 ? diff.set : undefined, + }, + + // V2 extended fields + locator: hints.debugSource ? { ...locator, debugSource: hints.debugSource } : locator, + selectorCandidates: locator.selectors?.slice(0, 8), + debugSource: hints.debugSource, + operation: { + type: 'update_style', + before: diff.before, + after: diff.after, + removed: diff.removed, + }, + }; + + return payload; +} + +/** + * Send Apply payload to background script + */ +export async function sendApplyPayload(payload: ApplyPayload): Promise { + if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { + throw new Error('Chrome runtime API not available'); + } + + return chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY, + payload, + }); +} + +/** + * Build and send Transaction to Agent in one call + */ +export async function sendTransactionToAgent( + tx: Transaction, + options: BuildPayloadOptions = {}, +): Promise { + const payload = buildApplyPayload(tx, options); + if (!payload) { + throw new Error('Unable to build payload from transaction'); + } + return sendApplyPayload(payload); +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/position-tracker.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/position-tracker.ts new file mode 100644 index 0000000..55398fa --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/position-tracker.ts @@ -0,0 +1,288 @@ +/** + * Position Tracker + * + * Keeps hover/selection rectangles in sync with viewport changes (scroll/resize). + * + * Design: + * - Uses passive scroll/resize listeners to avoid blocking scrolling + * - Coalesces updates with requestAnimationFrame so layout is read at most once per frame + * - Drops references to elements that are no longer connected to the DOM + * - Only emits updates when rect values actually change (with epsilon tolerance) + * + * Performance considerations: + * - getBoundingClientRect() calls are batched to single rAF + * - Sub-pixel jitter is filtered to reduce unnecessary redraws + * - Passive listeners don't block smooth scrolling + */ + +import type { ViewportRect } from '../overlay/canvas-overlay'; +import { Disposer } from '../utils/disposables'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Options for creating the position tracker */ +export interface PositionTrackerOptions { + /** Callback when tracked positions change */ + onPositionUpdate: (rects: TrackedRects) => void; +} + +/** Container for tracked element rectangles */ +export interface TrackedRects { + /** Hover element rectangle (null if no hover) */ + hover: ViewportRect | null; + /** Selection element rectangle (null if no selection) */ + selection: ViewportRect | null; +} + +/** Position tracker public interface */ +export interface PositionTracker { + /** Set the element to track for hover */ + setHoverElement(element: Element | null): void; + /** Set the element to track for selection */ + setSelectionElement(element: Element | null): void; + /** Force a synchronous position update (useful for initialization) */ + forceUpdate(): void; + /** Cleanup listeners and pending rAF */ + dispose(): void; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Passive listener options for scroll/resize */ +const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true }; + +/** Sub-pixel threshold for rect comparison (avoids jitter-induced redraws) */ +const RECT_EPSILON = 0.5; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Convert DOMRect to ViewportRect, returning null for invalid values + */ +function toViewportRect(domRect: DOMRectReadOnly): ViewportRect | null { + const { left, top, width, height } = domRect; + + if ( + !Number.isFinite(left) || + !Number.isFinite(top) || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { + return null; + } + + // Ensure non-negative dimensions + return { + left, + top, + width: Math.max(0, width), + height: Math.max(0, height), + }; +} + +/** + * Check if two numbers are approximately equal + */ +function approximatelyEqual(a: number, b: number): boolean { + return Math.abs(a - b) < RECT_EPSILON; +} + +/** + * Check if two ViewportRects are approximately equal + */ +function rectApproximatelyEqual(a: ViewportRect | null, b: ViewportRect | null): boolean { + if (a === b) return true; + if (!a || !b) return false; + + return ( + approximatelyEqual(a.left, b.left) && + approximatelyEqual(a.top, b.top) && + approximatelyEqual(a.width, b.width) && + approximatelyEqual(a.height, b.height) + ); +} + +/** + * Check if two TrackedRects are approximately equal + */ +function trackedRectsEqual(a: TrackedRects, b: TrackedRects): boolean { + return ( + rectApproximatelyEqual(a.hover, b.hover) && rectApproximatelyEqual(a.selection, b.selection) + ); +} + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Create a position tracker for monitoring element positions. + * + * The tracker automatically updates when the viewport scrolls or resizes, + * and notifies via callback when tracked element positions change. + */ +export function createPositionTracker(options: PositionTrackerOptions): PositionTracker { + const { onPositionUpdate } = options; + const disposer = new Disposer(); + + // ========================================================================== + // State + // ========================================================================== + + let hoverElement: Element | null = null; + let selectionElement: Element | null = null; + let lastRects: TrackedRects = { hover: null, selection: null }; + + // Single rAF slot for coalescing updates + let rafId: number | null = null; + + // ========================================================================== + // RAF Management + // ========================================================================== + + function cancelRaf(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + disposer.add(cancelRaf); + + function scheduleUpdate(): void { + if (disposer.isDisposed) return; + if (rafId !== null) return; + + rafId = requestAnimationFrame(() => { + rafId = null; + updateIfChanged(); + }); + } + + // ========================================================================== + // Position Computation + // ========================================================================== + + /** + * Get element if still connected to DOM, null otherwise + */ + function resolveConnected(element: Element | null): Element | null { + if (!element) return null; + return element.isConnected ? element : null; + } + + /** + * Safely read element's viewport rect + */ + function readElementRect(element: Element | null): ViewportRect | null { + if (!element) return null; + try { + return toViewportRect(element.getBoundingClientRect()); + } catch { + return null; + } + } + + /** + * Compute current rects for all tracked elements + */ + function computeRects(): TrackedRects { + // Resolve elements, dropping stale references + const resolvedHover = resolveConnected(hoverElement); + const resolvedSelection = resolveConnected(selectionElement); + + // Clear stale element references + if (hoverElement && !resolvedHover) { + hoverElement = null; + } + if (selectionElement && !resolvedSelection) { + selectionElement = null; + } + + // Optimization: if both point to same element, read rect once + if (resolvedHover && resolvedSelection && resolvedHover === resolvedSelection) { + const rect = readElementRect(resolvedHover); + return { hover: rect, selection: rect }; + } + + return { + hover: readElementRect(resolvedHover), + selection: readElementRect(resolvedSelection), + }; + } + + /** + * Update rects and notify if changed + */ + function updateIfChanged(): void { + if (disposer.isDisposed) return; + + const nextRects = computeRects(); + if (trackedRectsEqual(nextRects, lastRects)) return; + + lastRects = nextRects; + onPositionUpdate(nextRects); + } + + // ========================================================================== + // Event Handlers + // ========================================================================== + + function handleViewportChange(): void { + // Fast-path: nothing to track and nothing rendered + if (!hoverElement && !selectionElement && !lastRects.hover && !lastRects.selection) { + return; + } + scheduleUpdate(); + } + + // ========================================================================== + // Event Registration + // ========================================================================== + + // Listen for scroll on window (captures most scrolling scenarios) + disposer.listen(window, 'scroll', handleViewportChange, PASSIVE_LISTENER); + + // Capture scroll events from scrollable containers (scroll doesn't bubble, + // so we use capture phase to intercept scroll events from nested containers) + disposer.listen(document, 'scroll', handleViewportChange, { ...PASSIVE_LISTENER, capture: true }); + + // Listen for resize + disposer.listen(window, 'resize', handleViewportChange, PASSIVE_LISTENER); + + // ========================================================================== + // Public API + // ========================================================================== + + function setHoverElement(element: Element | null): void { + if (disposer.isDisposed) return; + if (hoverElement === element) return; + hoverElement = element; + scheduleUpdate(); + } + + function setSelectionElement(element: Element | null): void { + if (disposer.isDisposed) return; + if (selectionElement === element) return; + selectionElement = element; + scheduleUpdate(); + } + + function forceUpdate(): void { + if (disposer.isDisposed) return; + cancelRaf(); + updateIfChanged(); + } + + return { + setHoverElement, + setSelectionElement, + forceUpdate, + dispose: () => disposer.dispose(), + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/transaction-manager.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/transaction-manager.ts new file mode 100644 index 0000000..118c24d --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/transaction-manager.ts @@ -0,0 +1,592 @@ +/** + * Transaction Manager + * + * Locator-based undo/redo system for inline style edits. + * + * Design principles: + * - Uses CSS selectors (not DOM references) for element identification + * - Supports transaction merging for continuous edits (e.g., slider drag) + * - Provides handle-based API for batched operations + * - Emits change events for UI synchronization + */ + +import type { ElementLocator, Transaction, TransactionSnapshot } from '@/common/web-editor-types'; +import { Disposer } from '../utils/disposables'; +import { createElementLocator, locateElement, locatorKey } from './locator'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Change event action types */ +export type TransactionChangeAction = 'push' | 'merge' | 'undo' | 'redo' | 'clear' | 'rollback'; + +/** Change event emitted when transaction state changes */ +export interface TransactionChangeEvent { + action: TransactionChangeAction; + transaction: Transaction | null; + undoCount: number; + redoCount: number; +} + +/** Options for creating the Transaction Manager */ +export interface TransactionManagerOptions { + /** Maximum transactions to keep in history (oldest dropped) */ + maxHistory?: number; + /** Time window (ms) for merging consecutive edits to same property */ + mergeWindowMs?: number; + /** Enable Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z keyboard shortcuts */ + enableKeyBindings?: boolean; + /** Check if event is from editor UI (to ignore keybindings) */ + isEventFromEditorUi?: (event: Event) => boolean; + /** Custom time source (for testing) */ + now?: () => number; + /** Called when transaction state changes */ + onChange?: (event: TransactionChangeEvent) => void; + /** Called when applying a transaction fails */ + onApplyError?: (error: unknown) => void; +} + +/** Handle for an in-progress style transaction (for batching) */ +export interface StyleTransactionHandle { + /** Unique handle ID */ + readonly id: string; + /** CSS property being edited */ + readonly property: string; + /** Target element locator */ + readonly targetLocator: ElementLocator; + /** Update the style value (live preview) */ + set(value: string): void; + /** Commit the transaction and record to history */ + commit(options?: { merge?: boolean }): Transaction | null; + /** Rollback to original value without recording */ + rollback(): void; +} + +/** Transaction Manager public interface */ +export interface TransactionManager { + /** Begin an interactive style edit (returns handle for batching) */ + beginStyle(target: Element, property: string): StyleTransactionHandle | null; + /** Apply a style change immediately and record transaction */ + applyStyle( + target: Element, + property: string, + value: string, + options?: { merge?: boolean }, + ): Transaction | null; + /** Record a style transaction without applying (for external changes) */ + recordStyle( + locator: ElementLocator, + property: string, + beforeValue: string, + afterValue: string, + options?: { merge?: boolean }, + ): Transaction | null; + /** Undo the last transaction */ + undo(): Transaction | null; + /** Redo the last undone transaction */ + redo(): Transaction | null; + /** Check if undo is available */ + canUndo(): boolean; + /** Check if redo is available */ + canRedo(): boolean; + /** Get current undo stack (readonly) */ + getUndoStack(): readonly Transaction[]; + /** Get current redo stack (readonly) */ + getRedoStack(): readonly Transaction[]; + /** Clear all transaction history */ + clear(): void; + /** Cleanup resources */ + dispose(): void; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const DEFAULT_MAX_HISTORY = 100; +const DEFAULT_MERGE_WINDOW_MS = 800; + +const KEYBIND_OPTIONS: AddEventListenerOptions = { + capture: true, + passive: false, +}; + +// ============================================================================= +// Style Helpers +// ============================================================================= + +/** + * Normalize CSS property name to kebab-case. + * Preserves custom properties (--var-name). + */ +function normalizePropertyName(property: string): string { + const p = property.trim(); + if (!p) return ''; + + // Preserve custom properties + if (p.startsWith('--')) return p; + + // Already kebab-case + if (p.includes('-')) return p.toLowerCase(); + + // Convert camelCase to kebab-case + return p.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`).toLowerCase(); +} + +/** + * Safely get CSSStyleDeclaration from element + */ +function getInlineStyle(element: Element): CSSStyleDeclaration | null { + const htmlElement = element as HTMLElement; + const style = htmlElement.style; + + if (!style) return null; + if (typeof style.getPropertyValue !== 'function') return null; + if (typeof style.setProperty !== 'function') return null; + if (typeof style.removeProperty !== 'function') return null; + + return style; +} + +/** + * Read inline style property value + */ +function readStyleValue(style: CSSStyleDeclaration, property: string): string { + const prop = normalizePropertyName(property); + if (!prop) return ''; + return style.getPropertyValue(prop).trim(); +} + +/** + * Write inline style property value + */ +function writeStyleValue(style: CSSStyleDeclaration, property: string, value: string): void { + const prop = normalizePropertyName(property); + if (!prop) return; + + const v = value.trim(); + if (!v) { + style.removeProperty(prop); + } else { + style.setProperty(prop, v); + } +} + +/** + * Apply a styles snapshot to an element + */ +function applyStylesSnapshot(element: Element, styles: Record | undefined): void { + if (!styles) return; + + const inlineStyle = getInlineStyle(element); + if (!inlineStyle) return; + + for (const [property, value] of Object.entries(styles)) { + writeStyleValue(inlineStyle, property, value); + } +} + +// ============================================================================= +// Transaction Helpers +// ============================================================================= + +let transactionSeq = 0; + +/** + * Generate unique transaction ID + */ +function generateTransactionId(timestamp: number): string { + transactionSeq += 1; + return `tx_${timestamp.toString(36)}_${transactionSeq.toString(36)}`; +} + +/** + * Create a style transaction record + */ +function createStyleTransaction( + id: string, + locator: ElementLocator, + property: string, + beforeValue: string, + afterValue: string, + timestamp: number, +): Transaction { + const prop = normalizePropertyName(property); + + const beforeSnapshot: TransactionSnapshot = { + locator, + styles: { [prop]: beforeValue }, + }; + + const afterSnapshot: TransactionSnapshot = { + locator, + styles: { [prop]: afterValue }, + }; + + return { + id, + type: 'style', + targetLocator: locator, + before: beforeSnapshot, + after: afterSnapshot, + timestamp, + merged: false, + }; +} + +/** + * Get the single style property from a transaction (if applicable) + */ +function getSingleStyleProperty(tx: Transaction): string | null { + const keys = new Set(); + + if (tx.before.styles) { + for (const k of Object.keys(tx.before.styles)) keys.add(k); + } + if (tx.after.styles) { + for (const k of Object.keys(tx.after.styles)) keys.add(k); + } + + return keys.size === 1 ? Array.from(keys)[0]! : null; +} + +/** + * Check if two transactions can be merged + */ +function canMerge(prev: Transaction, next: Transaction, mergeWindowMs: number): boolean { + // Only merge style transactions + if (prev.type !== 'style' || next.type !== 'style') return false; + + // Check time window + if (Math.abs(next.timestamp - prev.timestamp) > mergeWindowMs) return false; + + // Check same target element + if (locatorKey(prev.targetLocator) !== locatorKey(next.targetLocator)) return false; + + // Check same property + const prevProp = getSingleStyleProperty(prev); + const nextProp = getSingleStyleProperty(next); + if (!prevProp || !nextProp || prevProp !== nextProp) return false; + + return true; +} + +/** + * Merge next transaction into prev (mutates prev) + */ +function mergeInto(prev: Transaction, next: Transaction): boolean { + const prop = getSingleStyleProperty(prev); + if (!prop) return false; + + const nextValue = next.after.styles?.[prop]; + if (nextValue === undefined) return false; + + // Update prev's after state + if (!prev.after.styles) prev.after.styles = {}; + prev.after.styles[prop] = nextValue; + prev.timestamp = next.timestamp; + prev.merged = true; + + return true; +} + +/** + * Apply a transaction (undo or redo) + * Returns true on success, false on failure + */ +function applyTransaction(tx: Transaction, direction: 'undo' | 'redo'): boolean { + if (tx.type !== 'style') return true; + + const target = locateElement(tx.targetLocator); + if (!target) { + return false; + } + + const snapshot = direction === 'undo' ? tx.before : tx.after; + applyStylesSnapshot(target, snapshot.styles); + return true; +} + +// ============================================================================= +// Transaction Manager Implementation +// ============================================================================= + +/** + * Create a Transaction Manager instance + */ +export function createTransactionManager( + options: TransactionManagerOptions = {}, +): TransactionManager { + const disposer = new Disposer(); + + // Configuration + const maxHistory = Math.max(1, options.maxHistory ?? DEFAULT_MAX_HISTORY); + const mergeWindowMs = Math.max(0, options.mergeWindowMs ?? DEFAULT_MERGE_WINDOW_MS); + const now = options.now ?? (() => Date.now()); + + // State + const undoStack: Transaction[] = []; + const redoStack: Transaction[] = []; + + // ========================================================================== + // Event Emission + // ========================================================================== + + function emit(action: TransactionChangeAction, transaction: Transaction | null): void { + options.onChange?.({ + action, + transaction, + undoCount: undoStack.length, + redoCount: redoStack.length, + }); + } + + // ========================================================================== + // Stack Management + // ========================================================================== + + function enforceMaxHistory(): void { + if (undoStack.length > maxHistory) { + undoStack.splice(0, undoStack.length - maxHistory); + } + } + + function pushTransaction(tx: Transaction, allowMerge: boolean): void { + const hadRedo = redoStack.length > 0; + + // Clear redo stack on new action + if (hadRedo) { + redoStack.length = 0; + } + + // Try to merge with previous transaction + if (!hadRedo && allowMerge && undoStack.length > 0) { + const last = undoStack[undoStack.length - 1]!; + if (canMerge(last, tx, mergeWindowMs) && mergeInto(last, tx)) { + emit('merge', last); + return; + } + } + + undoStack.push(tx); + enforceMaxHistory(); + emit('push', tx); + } + + // ========================================================================== + // Public API + // ========================================================================== + + function recordStyle( + locator: ElementLocator, + property: string, + beforeValue: string, + afterValue: string, + recordOptions?: { merge?: boolean }, + ): Transaction | null { + if (disposer.isDisposed) return null; + + const prop = normalizePropertyName(property); + if (!prop) return null; + + const before = beforeValue.trim(); + const after = afterValue.trim(); + if (before === after) return null; + + const id = generateTransactionId(now()); + const tx = createStyleTransaction(id, locator, prop, before, after, now()); + pushTransaction(tx, recordOptions?.merge !== false); + + return tx; + } + + function beginStyle(target: Element, property: string): StyleTransactionHandle | null { + if (disposer.isDisposed) return null; + + const inlineStyle = getInlineStyle(target); + if (!inlineStyle) return null; + + const prop = normalizePropertyName(property); + if (!prop) return null; + + const locator = createElementLocator(target); + const beforeValue = readStyleValue(inlineStyle, prop); + const id = generateTransactionId(now()); + + let completed = false; + + function set(value: string): void { + if (completed || disposer.isDisposed) return; + writeStyleValue(inlineStyle, prop, value); + } + + function commit(commitOptions?: { merge?: boolean }): Transaction | null { + if (completed || disposer.isDisposed) return null; + completed = true; + + const afterValue = readStyleValue(inlineStyle, prop); + if (afterValue === beforeValue) return null; + + const tx = createStyleTransaction(id, locator, prop, beforeValue, afterValue, now()); + pushTransaction(tx, commitOptions?.merge !== false); + return tx; + } + + function rollback(): void { + if (completed || disposer.isDisposed) return; + completed = true; + + writeStyleValue(inlineStyle, prop, beforeValue); + emit('rollback', null); + } + + return { + id, + property: prop, + targetLocator: locator, + set, + commit, + rollback, + }; + } + + function applyStyle( + target: Element, + property: string, + value: string, + applyOptions?: { merge?: boolean }, + ): Transaction | null { + const handle = beginStyle(target, property); + if (!handle) return null; + + handle.set(value); + return handle.commit(applyOptions); + } + + function undo(): Transaction | null { + if (disposer.isDisposed) return null; + + const tx = undoStack.pop(); + if (!tx) return null; + + // Try to apply the undo + const success = applyTransaction(tx, 'undo'); + if (!success) { + // Restore stack state on failure + undoStack.push(tx); + options.onApplyError?.(new Error(`Failed to locate element for undo: ${tx.id}`)); + return null; + } + + redoStack.push(tx); + emit('undo', tx); + return tx; + } + + function redo(): Transaction | null { + if (disposer.isDisposed) return null; + + const tx = redoStack.pop(); + if (!tx) return null; + + // Try to apply the redo + const success = applyTransaction(tx, 'redo'); + if (!success) { + // Restore stack state on failure + redoStack.push(tx); + options.onApplyError?.(new Error(`Failed to locate element for redo: ${tx.id}`)); + return null; + } + + undoStack.push(tx); + enforceMaxHistory(); + emit('redo', tx); + return tx; + } + + function canUndo(): boolean { + return undoStack.length > 0; + } + + function canRedo(): boolean { + return redoStack.length > 0; + } + + function getUndoStack(): readonly Transaction[] { + return undoStack.slice(); + } + + function getRedoStack(): readonly Transaction[] { + return redoStack.slice(); + } + + function clear(): void { + undoStack.length = 0; + redoStack.length = 0; + emit('clear', null); + } + + // ========================================================================== + // Keyboard Bindings + // ========================================================================== + + if (options.enableKeyBindings) { + disposer.listen( + window, + 'keydown', + (event: KeyboardEvent) => { + // Skip if event is from editor UI + if (options.isEventFromEditorUi?.(event)) return; + + // Check for Ctrl/Cmd modifier + const isMod = event.metaKey || event.ctrlKey; + if (!isMod || event.altKey) return; + + const key = event.key.toLowerCase(); + + // Ctrl/Cmd+Z: Undo, Ctrl/Cmd+Shift+Z: Redo, Ctrl/Cmd+Y: Redo + if (key === 'z') { + if (event.shiftKey) { + redo(); + } else { + undo(); + } + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } else if (key === 'y') { + redo(); + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } + }, + KEYBIND_OPTIONS, + ); + } + + // ========================================================================== + // Cleanup + // ========================================================================== + + function dispose(): void { + undoStack.length = 0; + redoStack.length = 0; + disposer.dispose(); + } + + return { + beginStyle, + applyStyle, + recordStyle, + undo, + redo, + canUndo, + canRedo, + getUndoStack, + getRedoStack, + clear, + dispose, + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/overlay/canvas-overlay.ts b/app/chrome-extension/entrypoints/web-editor-v2/overlay/canvas-overlay.ts new file mode 100644 index 0000000..5044475 --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/overlay/canvas-overlay.ts @@ -0,0 +1,346 @@ +/** + * Canvas Overlay + * + * High-performance overlay renderer for visual feedback (hover, selection, guides). + * + * Features: + * - DPR-aware rendering for crisp visuals on HiDPI displays + * - rAF-coalesced rendering via markDirty() pattern + * - ResizeObserver-backed automatic sizing + * - Separate layers for hover, selection, and future guides + * + * Performance considerations: + * - Uses `desynchronized: true` for lower latency + * - Batches all drawing to single rAF + * - Only redraws when dirty flag is set + * - Pixel-aligned strokes for crisp lines + */ + +import { WEB_EDITOR_V2_COLORS, WEB_EDITOR_V2_LOG_PREFIX } from '../constants'; +import { Disposer } from '../utils/disposables'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Rectangle in viewport coordinates */ +export type ViewportRect = Pick; + +/** Box style configuration */ +export interface BoxStyle { + /** Stroke color */ + strokeColor: string; + /** Fill color (with alpha for transparency) */ + fillColor: string; + /** Line width in CSS pixels */ + lineWidth: number; + /** Dash pattern (empty array for solid line) */ + dashPattern: number[]; +} + +/** Canvas overlay interface */ +export interface CanvasOverlay { + /** The underlying canvas element */ + canvas: HTMLCanvasElement; + /** Mark state as dirty and schedule a render on next animation frame */ + markDirty(): void; + /** Render immediately if dirty (called by RAF engine) */ + render(): void; + /** Clear all visual elements */ + clear(): void; + /** Update hover highlight */ + setHoverRect(rect: ViewportRect | null): void; + /** Update selection highlight */ + setSelectionRect(rect: ViewportRect | null): void; + /** Dispose and cleanup */ + dispose(): void; +} + +/** Options for creating canvas overlay */ +export interface CanvasOverlayOptions { + /** Container element (should be overlayRoot from ShadowHost) */ + container: HTMLElement; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const CANVAS_ATTR = 'data-mcp-canvas'; +const CANVAS_ATTR_VALUE = 'overlay'; + +/** Default styles for different box types */ +const BOX_STYLES = { + hover: { + strokeColor: WEB_EDITOR_V2_COLORS.hover, + fillColor: `${WEB_EDITOR_V2_COLORS.hover}15`, // 15 = ~8% opacity + lineWidth: 2, + dashPattern: [6, 4], + }, + selection: { + strokeColor: WEB_EDITOR_V2_COLORS.selected, + fillColor: `${WEB_EDITOR_V2_COLORS.selected}20`, // 20 = ~12% opacity + lineWidth: 2, + dashPattern: [], + }, +} satisfies Record; + +// ============================================================================= +// Helpers +// ============================================================================= + +function isFinitePositive(value: number): boolean { + return Number.isFinite(value) && value > 0; +} + +function isValidRect(rect: ViewportRect | null): rect is ViewportRect { + if (!rect) return false; + return ( + Number.isFinite(rect.left) && + Number.isFinite(rect.top) && + isFinitePositive(rect.width) && + isFinitePositive(rect.height) + ); +} + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Create a canvas overlay for rendering visual feedback. + */ +export function createCanvasOverlay(options: CanvasOverlayOptions): CanvasOverlay { + const { container } = options; + const disposer = new Disposer(); + + // Cleanup any existing canvas from previous instance + const existing = container.querySelector( + `canvas[${CANVAS_ATTR}="${CANVAS_ATTR_VALUE}"]`, + ); + if (existing) { + existing.remove(); + } + + // Create canvas element + const canvas = document.createElement('canvas'); + canvas.setAttribute(CANVAS_ATTR, CANVAS_ATTR_VALUE); + canvas.setAttribute('aria-hidden', 'true'); + + // Style for fullscreen coverage + Object.assign(canvas.style, { + position: 'absolute', + inset: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + display: 'block', + }); + + container.append(canvas); + disposer.add(() => canvas.remove()); + + // Get 2D context with performance options + const ctx = canvas.getContext('2d', { + alpha: true, + desynchronized: true, // Lower latency on supported browsers + }); + + if (!ctx) { + disposer.dispose(); + throw new Error(`${WEB_EDITOR_V2_LOG_PREFIX} Failed to get canvas 2D context`); + } + + // ========================================================================== + // State + // ========================================================================== + + let hoverRect: ViewportRect | null = null; + let selectionRect: ViewportRect | null = null; + + let viewportWidth = 1; + let viewportHeight = 1; + let devicePixelRatio = 1; + + let dirty = true; + let rafId: number | null = null; + + // ========================================================================== + // RAF Management + // ========================================================================== + + function cancelRaf(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + disposer.add(cancelRaf); + + function scheduleRaf(): void { + if (rafId !== null || disposer.isDisposed) return; + rafId = requestAnimationFrame(() => { + rafId = null; + render(); + }); + } + + // ========================================================================== + // Canvas Sizing (DPR-aware) + // ========================================================================== + + function updateCanvasSize(): boolean { + const nextDpr = Math.max(1, window.devicePixelRatio || 1); + const cssWidth = Math.max(1, viewportWidth); + const cssHeight = Math.max(1, viewportHeight); + + const pixelWidth = Math.round(cssWidth * nextDpr); + const pixelHeight = Math.round(cssHeight * nextDpr); + + const needsResize = + canvas.width !== pixelWidth || + canvas.height !== pixelHeight || + Math.abs(devicePixelRatio - nextDpr) > 0.001; + + if (!needsResize) return false; + + devicePixelRatio = nextDpr; + canvas.width = pixelWidth; + canvas.height = pixelHeight; + + // Reset transform after resize (canvas state is cleared) + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + + return true; + } + + // ========================================================================== + // Drawing Functions + // ========================================================================== + + function clearCanvas(): void { + updateCanvasSize(); + ctx.clearRect(0, 0, viewportWidth, viewportHeight); + } + + function drawBox(rect: ViewportRect | null, style: BoxStyle): void { + if (!isValidRect(rect)) return; + + const w = Math.round(rect.width); + const h = Math.round(rect.height); + if (w <= 0 || h <= 0) return; + + // Pixel-align for crisp strokes (add 0.5 for even line widths) + const x = Math.round(rect.left) + 0.5; + const y = Math.round(rect.top) + 0.5; + + ctx.save(); + + // Configure stroke + ctx.lineWidth = style.lineWidth; + ctx.strokeStyle = style.strokeColor; + ctx.fillStyle = style.fillColor; + ctx.setLineDash(style.dashPattern); + + // Draw rectangle + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.fill(); + ctx.stroke(); + + ctx.restore(); + } + + // ========================================================================== + // Public API + // ========================================================================== + + function markDirty(): void { + if (disposer.isDisposed) return; + dirty = true; + scheduleRaf(); + } + + function render(): void { + if (disposer.isDisposed || !dirty) return; + + // Cancel any pending RAF (in case render() is called manually) + cancelRaf(); + + // Reset dirty flag before drawing + dirty = false; + + // Clear and redraw + clearCanvas(); + drawBox(hoverRect, BOX_STYLES.hover); + drawBox(selectionRect, BOX_STYLES.selection); + + // If something marked dirty during render, schedule another frame + if (dirty) { + scheduleRaf(); + } + } + + function setHoverRect(rect: ViewportRect | null): void { + hoverRect = rect; + markDirty(); + } + + function setSelectionRect(rect: ViewportRect | null): void { + selectionRect = rect; + markDirty(); + } + + function clear(): void { + hoverRect = null; + selectionRect = null; + markDirty(); + } + + // ========================================================================== + // Initialization + // ========================================================================== + + // Initial size measurement + try { + const rect = container.getBoundingClientRect(); + viewportWidth = Math.max(1, rect.width); + viewportHeight = Math.max(1, rect.height); + } catch (error) { + console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} Initial size measurement failed:`, error); + } + + // Setup ResizeObserver for automatic sizing + disposer.observeResize(container, (entries) => { + const entry = entries[0]; + const rect = entry?.contentRect; + if (!rect) return; + + const nextWidth = Math.max(1, rect.width); + const nextHeight = Math.max(1, rect.height); + + // Skip if size hasn't changed significantly + if (Math.abs(nextWidth - viewportWidth) < 0.5 && Math.abs(nextHeight - viewportHeight) < 0.5) { + return; + } + + viewportWidth = nextWidth; + viewportHeight = nextHeight; + markDirty(); + }); + + // Initial render + markDirty(); + + return { + canvas, + markDirty, + render, + clear, + setHoverRect, + setSelectionRect, + dispose: () => disposer.dispose(), + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/selection/selection-engine.ts b/app/chrome-extension/entrypoints/web-editor-v2/selection/selection-engine.ts new file mode 100644 index 0000000..69b594e --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/selection/selection-engine.ts @@ -0,0 +1,672 @@ +/** + * Selection Engine (Phase 1.6 - Basic) + * + * Heuristic-based target picking to reduce noisy selections. + * + * Goals: + * - Skip invisible/transparent elements + * - De-prioritize "wrapper-only" elements (single-child, no visual boundary) + * - Prefer interactive elements (button/link/input/etc.) + * - Prefer elements with visual boundaries (border/background/shadow) + * - Support basic parent drilling via Alt modifier + * + * Scoring system: + * - Positive scores: interactive elements, visual boundaries, appropriate size + * - Negative scores: wrapper-only, too small/large, SVG internals + * - Candidates sorted by score descending, then by DOM order + */ + +import { Disposer } from '../utils/disposables'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Options for creating the selection engine */ +export interface SelectionEngineOptions { + /** Check if a DOM node belongs to the editor overlay */ + isOverlayElement: (node: unknown) => boolean; +} + +/** A scored selection candidate */ +export interface SelectionCandidate { + /** The candidate element */ + element: Element; + /** Heuristic score (higher = better target) */ + score: number; + /** Debug reasons explaining the score */ + reasons: string[]; +} + +/** Keyboard modifiers for selection behavior */ +export interface Modifiers { + alt: boolean; + shift: boolean; + ctrl: boolean; + meta: boolean; +} + +/** Selection engine public interface */ +export interface SelectionEngine { + /** Find the best target at a viewport point with modifier support */ + findBestTarget(x: number, y: number, modifiers: Modifiers): Element | null; + /** Get scored candidates at a point (for debugging or drill-up UI) */ + getCandidatesAtPoint(x: number, y: number): SelectionCandidate[]; + /** Get a meaningful parent candidate (for Alt drill-up) */ + getParentCandidate(current: Element): Element | null; + /** Cleanup */ + dispose(): void; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Max elements from elementsFromPoint to process */ +const MAX_HIT_ELEMENTS = 8; + +/** Max ancestor depth to traverse */ +const MAX_ANCESTOR_DEPTH = 6; + +/** Max total candidates to consider */ +const MAX_CANDIDATES = 60; + +/** Epsilon for rect comparisons */ +const RECT_EPSILON = 0.5; + +/** Tags that are inherently interactive */ +const INTERACTIVE_TAGS = new Set([ + 'A', + 'BUTTON', + 'INPUT', + 'SELECT', + 'TEXTAREA', + 'LABEL', + 'SUMMARY', + 'DETAILS', +]); + +/** ARIA roles that indicate interactivity */ +const INTERACTIVE_ROLES = new Set([ + 'button', + 'link', + 'checkbox', + 'radio', + 'switch', + 'tab', + 'menuitem', + 'option', + 'combobox', + 'textbox', +]); + +/** Tags commonly used as layout wrappers */ +const WRAPPER_TAGS = new Set([ + 'DIV', + 'SPAN', + 'SECTION', + 'ARTICLE', + 'MAIN', + 'HEADER', + 'FOOTER', + 'NAV', + 'ASIDE', +]); + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Parse a CSS numeric value + */ +function parseNumber(value: string): number { + const n = Number.parseFloat(value); + return Number.isFinite(n) ? n : 0; +} + +/** + * Check if a color is effectively transparent + */ +function isTransparentColor(value: string): boolean { + const v = value.trim().toLowerCase(); + if (v === 'transparent') return true; + + // Check rgba() with alpha <= 0.01 + const rgba = v.match(/^rgba?\((.+)\)$/); + if (rgba) { + const parts = rgba[1].split(',').map((p) => p.trim()); + if (parts.length >= 4) { + const alpha = Number.parseFloat(parts[3]); + return Number.isFinite(alpha) && alpha <= 0.01; + } + // rgb() without alpha is opaque + return false; + } + + // Check hsla() with alpha <= 0.01 + const hsla = v.match(/^hsla?\((.+)\)$/); + if (hsla) { + const parts = hsla[1].split(',').map((p) => p.trim()); + if (parts.length >= 4) { + const alpha = Number.parseFloat(parts[3]); + return Number.isFinite(alpha) && alpha <= 0.01; + } + return false; + } + + // hex and other formats are opaque + return false; +} + +/** + * Check if element has direct text content (not just whitespace) + */ +function hasDirectNonWhitespaceText(element: Element): boolean { + for (const node of Array.from(element.childNodes)) { + if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) { + return true; + } + } + return false; +} + +/** + * Get parent element, crossing Shadow DOM boundaries + */ +function getParentElementOrHost(element: Element): Element | null { + if (element.parentElement) return element.parentElement; + + try { + const root = element.getRootNode?.(); + if (root instanceof ShadowRoot) { + return root.host; + } + } catch { + // Ignore and fall back to null + } + + return null; +} + +/** + * Get elements at a viewport point + */ +function getHitElementsAtPoint(x: number, y: number): Element[] { + if (!Number.isFinite(x) || !Number.isFinite(y)) return []; + + try { + if (typeof document.elementsFromPoint === 'function') { + return document.elementsFromPoint(x, y); + } + } catch { + // Fall back to elementFromPoint + } + + const el = document.elementFromPoint(x, y); + return el ? [el] : []; +} + +/** + * Get viewport area for size ratio calculations + */ +function getViewportArea(): number { + const w = Math.max(1, window.innerWidth || 1); + const h = Math.max(1, window.innerHeight || 1); + return w * h; +} + +/** + * Safely read element rect + */ +function readRect(element: Element): DOMRectReadOnly | null { + try { + const rect = element.getBoundingClientRect(); + if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) return null; + if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) return null; + return rect; + } catch { + return null; + } +} + +/** + * Check if element is effectively invisible + */ +function isEffectivelyInvisible(style: CSSStyleDeclaration, rect: DOMRectReadOnly): boolean { + if (style.display === 'none') return true; + if (style.visibility === 'hidden' || style.visibility === 'collapse') return true; + if (parseNumber(style.opacity) <= 0.01) return true; + + // Check contentVisibility + const contentVisibility = (style as Record).contentVisibility; + if (contentVisibility === 'hidden') return true; + + // Zero-dimension elements + if (rect.width <= RECT_EPSILON || rect.height <= RECT_EPSILON) return true; + + return false; +} + +/** + * Score element based on visual boundary presence + */ +function getVisualBoundaryScore( + element: Element, + style: CSSStyleDeclaration, +): { points: number; reasons: string[] } { + let points = 0; + const reasons: string[] = []; + + // Background color or image + if (!isTransparentColor(style.backgroundColor) || style.backgroundImage !== 'none') { + points += 2; + reasons.push('visual:background:+2'); + } + + // Border + const borderWidths = [ + parseNumber(style.borderTopWidth), + parseNumber(style.borderRightWidth), + parseNumber(style.borderBottomWidth), + parseNumber(style.borderLeftWidth), + ]; + const hasBorder = + borderWidths.some((w) => w > RECT_EPSILON) && + (style.borderTopStyle !== 'none' || + style.borderRightStyle !== 'none' || + style.borderBottomStyle !== 'none' || + style.borderLeftStyle !== 'none'); + if (hasBorder) { + points += 3; + reasons.push('visual:border:+3'); + } + + // Box shadow + if (style.boxShadow && style.boxShadow !== 'none') { + points += 2; + reasons.push('visual:shadow:+2'); + } + + // Outline + if (style.outlineStyle !== 'none' && parseNumber(style.outlineWidth) > RECT_EPSILON) { + points += 1; + reasons.push('visual:outline:+1'); + } + + // Media elements are visually meaningful + const tag = element.tagName.toUpperCase(); + if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'SVG') { + points += 2; + reasons.push('visual:media:+2'); + } + + // SVG sub-elements usually aren't meaningful targets + if (element instanceof SVGElement && tag !== 'SVG') { + points -= 1; + reasons.push('visual:svg-sub:-1'); + } + + return { points, reasons }; +} + +/** + * Score element based on interactivity + */ +function getInteractivityScore( + element: Element, + style: CSSStyleDeclaration, +): { points: number; reasons: string[] } { + let points = 0; + const reasons: string[] = []; + + const tag = element.tagName.toUpperCase(); + + // Interactive tags + if (INTERACTIVE_TAGS.has(tag)) { + points += 6; + reasons.push(`type:${tag.toLowerCase()}:+6`); + } + + // Interactive roles + const role = element.getAttribute('role')?.toLowerCase() ?? ''; + if (role && INTERACTIVE_ROLES.has(role)) { + points += 4; + reasons.push(`role:${role}:+4`); + } + + // Anchor with href + if (element instanceof HTMLAnchorElement && element.href) { + points += 2; + reasons.push('attr:href:+2'); + } + + // Content editable + if (element instanceof HTMLElement) { + if (element.isContentEditable) { + points += 5; + reasons.push('attr:contenteditable:+5'); + } + + // Focusable + if (element.tabIndex >= 0) { + points += 2; + reasons.push('focusable:+2'); + } + } + + // Pointer cursor often indicates clickability + if (style.cursor === 'pointer') { + points += 2; + reasons.push('cursor:pointer:+2'); + } + + return { points, reasons }; +} + +/** + * Score element based on size + */ +function getSizeScore( + rect: DOMRectReadOnly, + viewportArea: number, +): { points: number; reasons: string[] } { + let points = 0; + const reasons: string[] = []; + + const area = rect.width * rect.height; + if (!Number.isFinite(area) || area <= 0) { + points -= 6; + reasons.push('size:invalid:-6'); + return { points, reasons }; + } + + // Too small: hard to interact with + if (rect.width < 4 || rect.height < 4) { + points -= 6; + reasons.push('size:tiny:-6'); + } else if (area < 16 * 16) { + points -= 4; + reasons.push('size:small:-4'); + } else if (area < 44 * 44) { + // Below recommended tap target size + points -= 1; + reasons.push('size:below-tap-target:-1'); + } + + // Too large: likely a layout container + const ratio = viewportArea > 0 ? area / viewportArea : 0; + if (ratio > 0.85) { + points -= 8; + reasons.push('size:huge:-8'); + } else if (ratio > 0.6) { + points -= 4; + reasons.push('size:very-large:-4'); + } + + return { points, reasons }; +} + +/** + * Check if element has meaningful padding + */ +function hasMeaningfulPadding(style: CSSStyleDeclaration): boolean { + return ( + parseNumber(style.paddingTop) > RECT_EPSILON || + parseNumber(style.paddingRight) > RECT_EPSILON || + parseNumber(style.paddingBottom) > RECT_EPSILON || + parseNumber(style.paddingLeft) > RECT_EPSILON + ); +} + +/** + * Check if element is a wrapper-only container + */ +function isWrapperOnly( + element: Element, + style: CSSStyleDeclaration, + visualScore: number, + interactivityScore: number, +): boolean { + // display: contents has no box + if (style.display === 'contents') return true; + + // Interactive elements are never pure wrappers + if (interactivityScore > 0) return false; + + // Only check common wrapper tags + const tag = element.tagName.toUpperCase(); + if (!WRAPPER_TAGS.has(tag)) return false; + + // Must have exactly one child element + if (element.children.length !== 1) return false; + + // Has direct text content = meaningful + if (hasDirectNonWhitespaceText(element)) return false; + + // Has visual boundary = meaningful + if (visualScore > 0) return false; + + // Has padding = meaningful + if (hasMeaningfulPadding(style)) return false; + + return true; +} + +/** Metadata for candidate ordering */ +interface CandidateMeta { + hitOrder: number; + depthFromHit: number; +} + +/** + * Compare candidate metadata for ordering + */ +function compareMeta(a: CandidateMeta, b: CandidateMeta): number { + if (a.hitOrder !== b.hitOrder) return a.hitOrder - b.hitOrder; + return a.depthFromHit - b.depthFromHit; +} + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Create a selection engine for intelligent element picking. + */ +export function createSelectionEngine(options: SelectionEngineOptions): SelectionEngine { + const disposer = new Disposer(); + const { isOverlayElement } = options; + + /** + * Score a single element + */ + function scoreElement( + element: Element, + styleCache: Map, + viewportArea: number, + ): SelectionCandidate | null { + // Basic filters + if (!element.isConnected) return null; + if (isOverlayElement(element)) return null; + + const tag = element.tagName.toUpperCase(); + if (tag === 'HTML' || tag === 'BODY') return null; + + const rect = readRect(element); + if (!rect) return null; + + // Get or cache computed style + let style = styleCache.get(element); + if (!style) { + style = window.getComputedStyle(element); + styleCache.set(element, style); + } + + if (isEffectivelyInvisible(style, rect)) return null; + + // Calculate scores + const reasons: string[] = []; + let score = 0; + + const interactivity = getInteractivityScore(element, style); + score += interactivity.points; + reasons.push(...interactivity.reasons); + + const visual = getVisualBoundaryScore(element, style); + score += visual.points; + reasons.push(...visual.reasons); + + const size = getSizeScore(rect, viewportArea); + score += size.points; + reasons.push(...size.reasons); + + // Penalize wrapper-only containers + if (isWrapperOnly(element, style, visual.points, interactivity.points)) { + score -= 8; + reasons.push('wrapperOnly:-8'); + } + + // De-prioritize generic inline spans + if (tag === 'SPAN' && interactivity.points === 0 && visual.points === 0) { + score -= 2; + reasons.push('inline:span:-2'); + } + + // Large fixed elements are often overlays/headers + const area = rect.width * rect.height; + const ratio = viewportArea > 0 ? area / viewportArea : 0; + if (style.position === 'fixed' && ratio > 0.3) { + score -= 2; + reasons.push('position:fixed-large:-2'); + } + + return { element, score, reasons }; + } + + /** + * Get all scored candidates at a point + */ + function getCandidatesAtPoint(x: number, y: number): SelectionCandidate[] { + const hit = getHitElementsAtPoint(x, y); + if (hit.length === 0) return []; + + // Collect candidates with metadata + const map = new Map(); + + function addCandidate(element: Element, meta: CandidateMeta): void { + if (isOverlayElement(element)) return; + if (map.size >= MAX_CANDIDATES && !map.has(element)) return; + + const prev = map.get(element); + if (!prev || compareMeta(meta, prev) < 0) { + map.set(element, meta); + } + } + + // Process hit elements and their ancestors + const limit = Math.min(hit.length, MAX_HIT_ELEMENTS); + for (let i = 0; i < limit; i++) { + const el = hit[i]; + addCandidate(el, { hitOrder: i, depthFromHit: 0 }); + + // Traverse ancestors + let current: Element | null = el; + for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH; depth++) { + current = current ? getParentElementOrHost(current) : null; + if (!current) break; + addCandidate(current, { hitOrder: i, depthFromHit: depth }); + } + } + + // Score all candidates + const viewportArea = getViewportArea(); + const styleCache = new Map(); + + const scored: Array = []; + for (const [element, meta] of map) { + const candidate = scoreElement(element, styleCache, viewportArea); + if (!candidate) continue; + scored.push({ ...candidate, ...meta }); + } + + // Sort by score (descending), then by DOM order + scored.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return compareMeta(a, b); + }); + + // Strip metadata from result + return scored.map(({ hitOrder: _, depthFromHit: __, ...c }) => c); + } + + /** + * Get a meaningful parent candidate for drill-up + */ + function getParentCandidate(current: Element): Element | null { + let parent = getParentElementOrHost(current); + if (!parent) return null; + + const viewportArea = getViewportArea(); + const styleCache = new Map(); + + while (parent) { + if (isOverlayElement(parent)) return null; + + const tag = parent.tagName.toUpperCase(); + if (tag === 'HTML' || tag === 'BODY') return null; + + const rect = readRect(parent); + if (!rect) { + parent = getParentElementOrHost(parent); + continue; + } + + let style = styleCache.get(parent); + if (!style) { + style = window.getComputedStyle(parent); + styleCache.set(parent, style); + } + + if (isEffectivelyInvisible(style, rect)) { + parent = getParentElementOrHost(parent); + continue; + } + + const interactivity = getInteractivityScore(parent, style); + const visual = getVisualBoundaryScore(parent, style); + + // Return first non-wrapper parent + if (!isWrapperOnly(parent, style, visual.points, interactivity.points)) { + return parent; + } + + parent = getParentElementOrHost(parent); + } + + return null; + } + + /** + * Find the best target at a point with modifier support + */ + function findBestTarget(x: number, y: number, modifiers: Modifiers): Element | null { + const candidates = getCandidatesAtPoint(x, y); + const best = candidates[0]?.element ?? null; + if (!best) return null; + + // Alt modifier: drill up to parent + if (modifiers.alt) { + return getParentCandidate(best) ?? best; + } + + return best; + } + + return { + findBestTarget, + getCandidatesAtPoint, + getParentCandidate, + dispose: () => disposer.dispose(), + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts new file mode 100644 index 0000000..6a51ae5 --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts @@ -0,0 +1,486 @@ +/** + * Shadow DOM Host + * + * Creates an isolated container for the Web Editor UI using Shadow DOM. + * Provides: + * - Style isolation (no CSS bleed in/out) + * - Event isolation (UI events don't bubble to page) + * - Overlay container for Canvas/visual feedback + * - UI container for panels/controls + */ + +import { + WEB_EDITOR_V2_HOST_ID, + WEB_EDITOR_V2_OVERLAY_ID, + WEB_EDITOR_V2_UI_ID, + WEB_EDITOR_V2_Z_INDEX, +} from '../constants'; +import { Disposer } from '../utils/disposables'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Elements exposed by the shadow host */ +export interface ShadowHostElements { + /** The host element attached to the document */ + host: HTMLDivElement; + /** The shadow root */ + shadowRoot: ShadowRoot; + /** Container for overlay elements (Canvas, guides, etc.) */ + overlayRoot: HTMLDivElement; + /** Container for UI elements (panels, toolbar, etc.) */ + uiRoot: HTMLDivElement; +} + +/** Options for mounting the shadow host */ +export interface ShadowHostOptions { + /** Callback when user requests to close the editor */ + onRequestClose?: () => void; +} + +/** Interface for the shadow host manager */ +export interface ShadowHostManager { + /** Get the shadow host elements (null if not mounted) */ + getElements(): ShadowHostElements | null; + /** Check if a node is part of the editor overlay */ + isOverlayElement(node: unknown): boolean; + /** Check if an event originated from the editor UI */ + isEventFromUi(event: Event): boolean; + /** Dispose and unmount the shadow host */ + dispose(): void; +} + +// ============================================================================= +// Styles +// ============================================================================= + +const SHADOW_HOST_STYLES = /* css */ ` + :host { + all: initial; + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + /* Overlay container - for Canvas and visual feedback */ + #${WEB_EDITOR_V2_OVERLAY_ID} { + position: fixed; + inset: 0; + pointer-events: none; + contain: layout style; + } + + /* UI container - for panels and controls */ + #${WEB_EDITOR_V2_UI_ID} { + position: fixed; + top: 16px; + right: 16px; + pointer-events: auto; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 13px; + line-height: 1.4; + color: #0f172a; + -webkit-font-smoothing: antialiased; + } + + /* Panel styles */ + .we-panel { + width: 320px; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + background: rgba(255, 255, 255, 0.98); + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 12px; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 10px 20px -5px rgba(0, 0, 0, 0.15); + overflow: hidden; + contain: layout style paint; + } + + .we-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + background: rgba(248, 250, 252, 0.95); + border-bottom: 1px solid rgba(226, 232, 240, 0.8); + user-select: none; + } + + .we-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: #1e293b; + } + + .we-badge { + font-size: 10px; + font-weight: 500; + padding: 2px 6px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: white; + border-radius: 4px; + } + + .we-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + color: #475569; + background: white; + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + } + + .we-btn:hover { + background: #f8fafc; + border-color: rgba(148, 163, 184, 0.7); + } + + .we-btn:active { + background: #f1f5f9; + } + + .we-btn:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 2px; + } + + .we-btn:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .we-btn--primary { + background: linear-gradient(135deg, #0f172a, #1e293b); + color: #ffffff; + border-color: rgba(15, 23, 42, 0.5); + } + + .we-btn--primary:hover:not(:disabled) { + background: linear-gradient(135deg, #1e293b, #334155); + border-color: rgba(15, 23, 42, 0.65); + } + + .we-btn--danger { + color: #b91c1c; + border-color: rgba(248, 113, 113, 0.45); + } + + .we-btn--danger:hover:not(:disabled) { + background: rgba(248, 113, 113, 0.08); + border-color: rgba(248, 113, 113, 0.6); + } + + /* Toolbar */ + .we-toolbar { + position: fixed; + left: 50%; + top: 16px; + transform: translateX(-50%); + width: min(720px, calc(100vw - 32px)); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.98); + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 12px; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 10px 20px -5px rgba(0, 0, 0, 0.15); + pointer-events: auto; + user-select: none; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 13px; + color: #0f172a; + } + + .we-toolbar[data-position="bottom"] { + top: auto; + bottom: 16px; + } + + .we-toolbar-left, + .we-toolbar-right { + display: flex; + align-items: center; + gap: 8px; + } + + .we-toolbar-center { + flex: 1; + display: flex; + justify-content: center; + min-width: 0; + } + + .we-toolbar-meta { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 4px 12px; + background: rgba(248, 250, 252, 0.9); + border: 1px solid rgba(226, 232, 240, 0.9); + border-radius: 999px; + color: #475569; + font-size: 12px; + white-space: nowrap; + } + + .we-toolbar-status { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(100, 116, 139, 0.12); + color: #334155; + } + + .we-toolbar[data-status="idle"] .we-toolbar-status { + display: none; + } + + .we-toolbar[data-status="applying"] .we-toolbar-status { + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; + } + + .we-toolbar[data-status="success"] .we-toolbar-status { + background: rgba(34, 197, 94, 0.12); + color: #15803d; + } + + .we-toolbar[data-status="error"] .we-toolbar-status { + background: rgba(248, 113, 113, 0.14); + color: #b91c1c; + } + + .we-body { + padding: 14px; + color: #475569; + font-size: 12px; + } + + .we-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(34, 197, 94, 0.1); + border-radius: 6px; + color: #15803d; + font-size: 12px; + } + + .we-status-dot { + width: 8px; + height: 8px; + background: #22c55e; + border-radius: 50%; + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } +`; + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Set a CSS property with !important flag + */ +function setImportantStyle(element: HTMLElement, property: string, value: string): void { + element.style.setProperty(property, value, 'important'); +} + +/** + * Create the initial panel UI + */ +function createPanelContent(onRequestClose?: () => void): HTMLElement { + const panel = document.createElement('div'); + panel.className = 'we-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-label', 'Web Editor'); + + // Header + const header = document.createElement('div'); + header.className = 'we-header'; + + const title = document.createElement('div'); + title.className = 'we-title'; + title.innerHTML = ` + Web Editor + V2 + `; + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'we-btn'; + closeBtn.textContent = 'Exit'; + closeBtn.setAttribute('aria-label', 'Exit Web Editor'); + closeBtn.addEventListener('click', () => onRequestClose?.()); + + header.append(title, closeBtn); + + // Body + const body = document.createElement('div'); + body.className = 'we-body'; + + const status = document.createElement('div'); + status.className = 'we-status'; + status.innerHTML = ` + + Editor active - Hover to select elements + `; + body.append(status); + + panel.append(header, body); + return panel; +} + +/** + * Mount the Shadow DOM host and return a manager interface + */ +export function mountShadowHost(options: ShadowHostOptions = {}): ShadowHostManager { + const disposer = new Disposer(); + let elements: ShadowHostElements | null = null; + + // Clean up any existing host (from crash/reload) + const existing = document.getElementById(WEB_EDITOR_V2_HOST_ID); + if (existing) { + try { + existing.remove(); + } catch { + // Best-effort cleanup + } + } + + // Create host element + const host = document.createElement('div'); + host.id = WEB_EDITOR_V2_HOST_ID; + host.setAttribute('data-mcp-web-editor', 'v2'); + + // Apply host styles with !important to resist page CSS + setImportantStyle(host, 'position', 'fixed'); + setImportantStyle(host, 'inset', '0'); + setImportantStyle(host, 'z-index', String(WEB_EDITOR_V2_Z_INDEX)); + setImportantStyle(host, 'pointer-events', 'none'); + setImportantStyle(host, 'contain', 'layout style paint'); + setImportantStyle(host, 'isolation', 'isolate'); + + // Create shadow root + const shadowRoot = host.attachShadow({ mode: 'open' }); + + // Add styles + const styleEl = document.createElement('style'); + styleEl.textContent = SHADOW_HOST_STYLES; + shadowRoot.append(styleEl); + + // Create overlay container (for Canvas) + const overlayRoot = document.createElement('div'); + overlayRoot.id = WEB_EDITOR_V2_OVERLAY_ID; + + // Create UI container (for panels) + const uiRoot = document.createElement('div'); + uiRoot.id = WEB_EDITOR_V2_UI_ID; + uiRoot.append(createPanelContent(options.onRequestClose)); + + shadowRoot.append(overlayRoot, uiRoot); + + // Mount to document + const mountPoint = document.documentElement ?? document.body; + mountPoint.append(host); + disposer.add(() => host.remove()); + + elements = { host, shadowRoot, overlayRoot, uiRoot }; + + // Event isolation: prevent UI events from bubbling to page + const blockedEvents = [ + 'pointerdown', + 'pointerup', + 'pointermove', + 'pointerenter', + 'pointerleave', + 'mousedown', + 'mouseup', + 'mousemove', + 'mouseenter', + 'mouseleave', + 'click', + 'dblclick', + 'contextmenu', + 'keydown', + 'keyup', + 'keypress', + 'wheel', + 'touchstart', + 'touchmove', + 'touchend', + 'touchcancel', + 'focus', + 'blur', + 'input', + 'change', + ]; + + const stopPropagation = (event: Event) => { + event.stopPropagation(); + }; + + for (const eventType of blockedEvents) { + disposer.listen(uiRoot, eventType, stopPropagation); + } + + // Helper: check if a node is part of the editor + const isOverlayElement = (node: unknown): boolean => { + if (!(node instanceof Node)) return false; + if (node === host) return true; + + const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null; + return root instanceof ShadowRoot && root.host === host; + }; + + // Helper: check if an event came from the editor UI + const isEventFromUi = (event: Event): boolean => { + try { + if (typeof event.composedPath === 'function') { + return event.composedPath().some((el) => isOverlayElement(el)); + } + } catch { + // Fallback to target + } + return isOverlayElement(event.target); + }; + + return { + getElements: () => elements, + isOverlayElement, + isEventFromUi, + dispose: () => { + elements = null; + disposer.dispose(); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts new file mode 100644 index 0000000..03a9873 --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts @@ -0,0 +1,317 @@ +/** + * Toolbar UI (Phase 1.10) + * + * Shadow DOM toolbar with Apply / Undo / Redo / Close buttons. + * Displays transaction counts and operation status. + * + * Design: + * - Fixed position at top of viewport + * - Uses CSS classes defined in shadow-host.ts + * - Disposer pattern for cleanup + */ + +import { Disposer } from '../utils/disposables'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Toolbar position */ +export type ToolbarDock = 'top' | 'bottom'; + +/** Operation status */ +export type ToolbarStatus = 'idle' | 'applying' | 'success' | 'error'; + +/** Result from apply operation */ +export interface ApplyResult { + requestId?: string; +} + +/** Toolbar creation options */ +export interface ToolbarOptions { + /** Container element in Shadow DOM */ + container: HTMLElement; + /** Position (default: top) */ + dock?: ToolbarDock; + /** Called when Apply button is clicked */ + onApply?: () => void | ApplyResult | Promise; + /** Called when Undo button is clicked */ + onUndo?: () => void; + /** Called when Redo button is clicked */ + onRedo?: () => void; + /** Called when Close button is clicked */ + onRequestClose?: () => void; +} + +/** Toolbar public interface */ +export interface Toolbar { + /** Update undo/redo counts */ + setHistory(undoCount: number, redoCount: number): void; + /** Update status display */ + setStatus(status: ToolbarStatus, message?: string): void; + /** Cleanup */ + dispose(): void; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Check if value is Promise-like + */ +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + !!value && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +/** + * Check if value is ApplyResult + */ +function isApplyResult(value: unknown): value is ApplyResult { + if (!value || typeof value !== 'object') return false; + const req = (value as { requestId?: unknown }).requestId; + return req === undefined || typeof req === 'string'; +} + +/** + * Format status message with optional request ID + */ +function formatStatusMessage(base: string, result?: ApplyResult): string { + const req = result?.requestId ? `requestId=${result.requestId}` : ''; + return req ? `${base} (${req})` : base; +} + +// ============================================================================= +// Status Reset Timer +// ============================================================================= + +const STATUS_RESET_DELAY_MS = 2400; + +// ============================================================================= +// Implementation +// ============================================================================= + +/** + * Create a Toolbar UI component + */ +export function createToolbar(options: ToolbarOptions): Toolbar { + const disposer = new Disposer(); + const dock = options.dock ?? 'top'; + + // State + let undoCount = 0; + let redoCount = 0; + let status: ToolbarStatus = 'idle'; + let statusMessage = ''; + let applying = false; + let resetTimer: number | null = null; + + // ========================================================================== + // DOM Structure + // ========================================================================== + + // Root container + const root = document.createElement('div'); + root.className = 'we-toolbar'; + root.dataset.position = dock; + root.dataset.status = status; + root.setAttribute('role', 'toolbar'); + root.setAttribute('aria-label', 'Web Editor Toolbar'); + + // Left section: title + const left = document.createElement('div'); + left.className = 'we-toolbar-left'; + + const title = document.createElement('div'); + title.className = 'we-title'; + const titleText = document.createElement('span'); + titleText.textContent = 'Web Editor'; + const badge = document.createElement('span'); + badge.className = 'we-badge'; + badge.textContent = 'V2'; + title.append(titleText, badge); + left.append(title); + + // Center section: counts and status + const center = document.createElement('div'); + center.className = 'we-toolbar-center'; + + const meta = document.createElement('div'); + meta.className = 'we-toolbar-meta'; + + const countsEl = document.createElement('span'); + countsEl.className = 'we-toolbar-counts'; + + const statusEl = document.createElement('span'); + statusEl.className = 'we-toolbar-status'; + statusEl.setAttribute('aria-live', 'polite'); + + meta.append(countsEl, statusEl); + center.append(meta); + + // Right section: buttons + const right = document.createElement('div'); + right.className = 'we-toolbar-right'; + + const applyBtn = document.createElement('button'); + applyBtn.type = 'button'; + applyBtn.className = 'we-btn we-btn--primary'; + applyBtn.textContent = 'Apply'; + applyBtn.setAttribute('aria-label', 'Apply changes to code'); + + const undoBtn = document.createElement('button'); + undoBtn.type = 'button'; + undoBtn.className = 'we-btn'; + undoBtn.textContent = 'Undo'; + undoBtn.setAttribute('aria-label', 'Undo last change'); + + const redoBtn = document.createElement('button'); + redoBtn.type = 'button'; + redoBtn.className = 'we-btn'; + redoBtn.textContent = 'Redo'; + redoBtn.setAttribute('aria-label', 'Redo last undone change'); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'we-btn we-btn--danger'; + closeBtn.textContent = 'Close'; + closeBtn.setAttribute('aria-label', 'Close Web Editor'); + + right.append(applyBtn, undoBtn, redoBtn, closeBtn); + + // Assemble + root.append(left, center, right); + options.container.append(root); + disposer.add(() => root.remove()); + + // ========================================================================== + // Timer Management + // ========================================================================== + + function clearResetTimer(): void { + if (resetTimer !== null) { + window.clearTimeout(resetTimer); + resetTimer = null; + } + } + disposer.add(clearResetTimer); + + // ========================================================================== + // Render Functions + // ========================================================================== + + function renderCounts(): void { + countsEl.textContent = `Undo: ${undoCount} · Redo: ${redoCount}`; + } + + function renderButtons(): void { + undoBtn.disabled = applying || undoCount <= 0; + redoBtn.disabled = applying || redoCount <= 0; + applyBtn.disabled = applying || undoCount <= 0 || !options.onApply; + applyBtn.textContent = applying ? 'Applying…' : 'Apply'; + } + + function renderStatus(): void { + root.dataset.status = status; + statusEl.textContent = status === 'idle' ? '' : statusMessage; + } + + function scheduleStatusReset(): void { + clearResetTimer(); + resetTimer = window.setTimeout(() => setStatus('idle'), STATUS_RESET_DELAY_MS); + } + + // ========================================================================== + // Public Methods + // ========================================================================== + + function setHistory(nextUndo: number, nextRedo: number): void { + undoCount = Math.max(0, Math.floor(nextUndo)); + redoCount = Math.max(0, Math.floor(nextRedo)); + renderCounts(); + renderButtons(); + } + + function setStatus(nextStatus: ToolbarStatus, message?: string): void { + status = nextStatus; + statusMessage = (message ?? '').trim(); + renderStatus(); + + if (status === 'success' || status === 'error') { + scheduleStatusReset(); + } else { + clearResetTimer(); + } + } + + // ========================================================================== + // Event Handlers + // ========================================================================== + + async function handleApply(): Promise { + if (applyBtn.disabled) return; + if (!options.onApply) return; + + applying = true; + renderButtons(); + setStatus('applying', 'Sending…'); + + try { + const resultOrPromise = options.onApply(); + const result = isPromiseLike(resultOrPromise) ? await resultOrPromise : resultOrPromise; + const applyResult = isApplyResult(result) ? result : undefined; + setStatus('success', formatStatusMessage('Sent', applyResult)); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + setStatus('error', msg || 'Failed'); + } finally { + applying = false; + renderButtons(); + } + } + + // Apply button + disposer.listen(applyBtn, 'click', (event) => { + event.preventDefault(); + void handleApply(); + }); + + // Undo button + disposer.listen(undoBtn, 'click', (event) => { + event.preventDefault(); + if (undoBtn.disabled) return; + options.onUndo?.(); + }); + + // Redo button + disposer.listen(redoBtn, 'click', (event) => { + event.preventDefault(); + if (redoBtn.disabled) return; + options.onRedo?.(); + }); + + // Close button + disposer.listen(closeBtn, 'click', (event) => { + event.preventDefault(); + options.onRequestClose?.(); + }); + + // Initial render + renderCounts(); + renderButtons(); + renderStatus(); + + // ========================================================================== + // Return API + // ========================================================================== + + return { + setHistory, + setStatus, + dispose: () => disposer.dispose(), + }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/utils/disposables.ts b/app/chrome-extension/entrypoints/web-editor-v2/utils/disposables.ts new file mode 100644 index 0000000..a78c1ae --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/utils/disposables.ts @@ -0,0 +1,142 @@ +/** + * Disposables Utility + * + * Provides deterministic cleanup for event listeners, observers, and other resources. + * Ensures proper cleanup order (LIFO) and prevents memory leaks. + */ + +/** Function that performs cleanup */ +export type DisposeFn = () => void; + +/** + * Manages a collection of disposable resources. + * Resources are disposed in reverse order (LIFO). + */ +export class Disposer { + private disposed = false; + private readonly disposers: DisposeFn[] = []; + + /** Whether this disposer has already been disposed */ + get isDisposed(): boolean { + return this.disposed; + } + + /** + * Add a dispose function to be called during cleanup. + * If already disposed, the function is called immediately. + */ + add(dispose: DisposeFn): void { + if (this.disposed) { + try { + dispose(); + } catch { + // Best-effort cleanup for late additions + } + return; + } + this.disposers.push(dispose); + } + + /** + * Add an event listener and automatically remove it on dispose. + */ + listen( + target: Window, + type: K, + listener: (ev: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + ): void; + listen( + target: Document, + type: K, + listener: (ev: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + ): void; + listen( + target: HTMLElement, + type: K, + listener: (ev: HTMLElementEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + ): void; + listen( + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + listen( + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void { + target.addEventListener(type, listener, options); + this.add(() => target.removeEventListener(type, listener, options)); + } + + /** + * Add a ResizeObserver and automatically disconnect it on dispose. + */ + observeResize( + target: Element, + callback: ResizeObserverCallback, + options?: ResizeObserverOptions, + ): ResizeObserver { + const observer = new ResizeObserver(callback); + observer.observe(target, options); + this.add(() => observer.disconnect()); + return observer; + } + + /** + * Add a MutationObserver and automatically disconnect it on dispose. + */ + observeMutation( + target: Node, + callback: MutationCallback, + options?: MutationObserverInit, + ): MutationObserver { + const observer = new MutationObserver(callback); + observer.observe(target, options); + this.add(() => observer.disconnect()); + return observer; + } + + /** + * Add a requestAnimationFrame and automatically cancel it on dispose. + * Returns a function to manually cancel the frame. + */ + requestAnimationFrame(callback: FrameRequestCallback): () => void { + const id = requestAnimationFrame(callback); + let cancelled = false; + + const cancel = () => { + if (cancelled) return; + cancelled = true; + cancelAnimationFrame(id); + }; + + this.add(cancel); + return cancel; + } + + /** + * Dispose all registered resources in reverse order. + * Safe to call multiple times. + */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + + // Dispose in reverse order (LIFO) + for (let i = this.disposers.length - 1; i >= 0; i--) { + try { + this.disposers[i](); + } catch { + // Best-effort cleanup, continue with remaining disposers + } + } + + this.disposers.length = 0; + } +} diff --git a/app/chrome-extension/wxt.config.ts b/app/chrome-extension/wxt.config.ts index a3d171b..b86694c 100644 --- a/app/chrome-extension/wxt.config.ts +++ b/app/chrome-extension/wxt.config.ts @@ -72,18 +72,18 @@ export default defineConfig({ }, // Keyboard shortcuts for quick triggers commands: { - run_quick_trigger_1: { - suggested_key: { default: 'Ctrl+Shift+1' }, - description: 'Run quick trigger 1', - }, - run_quick_trigger_2: { - suggested_key: { default: 'Ctrl+Shift+2' }, - description: 'Run quick trigger 2', - }, - run_quick_trigger_3: { - suggested_key: { default: 'Ctrl+Shift+3' }, - description: 'Run quick trigger 3', - }, + // run_quick_trigger_1: { + // suggested_key: { default: 'Ctrl+Shift+1' }, + // description: 'Run quick trigger 1', + // }, + // run_quick_trigger_2: { + // suggested_key: { default: 'Ctrl+Shift+2' }, + // description: 'Run quick trigger 2', + // }, + // run_quick_trigger_3: { + // suggested_key: { default: 'Ctrl+Shift+3' }, + // description: 'Run quick trigger 3', + // }, open_workflow_sidepanel: { suggested_key: { default: 'Ctrl+Shift+O' }, description: 'Open workflow sidepanel', diff --git a/builder-plan.md b/builder-plan.md new file mode 100644 index 0000000..a5575ab --- /dev/null +++ b/builder-plan.md @@ -0,0 +1,1203 @@ +# 可视化编辑器 (Visual Editor) 重构计划 + +## 一、项目概述 + +### 1.1 目标 + +基于 editor.md 中的需求讨论,将现有玩具级的可视化编辑器重写为企业级、Webflow/Figma 级丝滑体验的前端可视化工作台。 + +### 1.2 现状分析 + +- **入口**: `toggleEditorInTab` in `app/chrome-extension/entrypoints/background/web-editor/index.ts:205` +- **注入脚本**: `app/chrome-extension/inject-scripts/web-editor.js` (约 850 行单体 JS) +- **当前能力**: + - 基础的 hover 高亮和 click 选中 + - Canvas 绘制选中框 (但性能有问题) + - 简单的 Text/Style 编辑浮层 + - Sync to Code 发送给 Agent +- **主要问题**: + - 无 Shadow DOM 隔离,样式易污染 + - mousemove 未节流,直接触发 layout + - 不支持 Shadow DOM 内部元素选择 + - 不支持 iframe + - payload fingerprint 过弱 + - 无拖拽重排能力 + - 无属性面板 (Design/CSS) + - 无事务系统 (Undo/Redo) + +### 1.3 技术决策 + +基于 codex 分析和 editor.md 方案,采用以下架构: + +- **AR 架构**: Canvas 负责视觉反馈,DOM API 负责实际操作 +- **Shadow DOM 隔离**: 所有编辑器 UI 在 ShadowRoot 内渲染 +- **性能优先**: rAF 驱动、读写分离、按需渲染 +- **渐进增强**: 基础模式 + 精准模式 (Vite 插件) +- **UI 技术栈**: Vue 3 (与项目保持一致,复用现有组件和构建链路) +- **事务系统**: 基于 Locator 而非 Element 引用 (支持 HMR/DOM 变更后恢复)(低优先级,先不考虑实现) + +--- + +## 二、功能点清单 + +### A. 画布交互与选中系统 + +| ID | 功能点 | 优先级 | 复杂度 | +| --- | ------------------------------------------------------- | ------ | ------ | +| A0 | 事件拦截与编辑模式控制 (stopPropagation/preventDefault) | P0 | 低 | +| A1 | Hover 高亮 (60FPS) | P0 | 中 | +| A2 | 智能去噪选中 (透明容器透传、视觉权重) | P0 | 高 | +| A3 | 单击选中 + 修饰键穿透/上钻 | P0 | 中 | +| A4 | 面包屑导航 (composedPath) | P1 | 中 | +| A5 | Shadow DOM 内部元素支持 | P1 | 高 | +| A6 | iframe 内部元素支持 | P2 | 高 | +| A7 | 多选与框选 | P2 | 高 | +| A8 | 组件实例识别 (结构指纹聚类) | P2 | 高 | +| A9 | 编辑器自身元素过滤 (避免选中 overlay/toolbar) | P0 | 低 | + +### B. 视觉渲染引擎 + +| ID | 功能点 | 优先级 | 复杂度 | +| --- | ------------------------------------ | ------ | ------ | +| B1 | Shadow DOM 宿主隔离 | P0 | 中 | +| B2 | Canvas Overlay 层 (选框/参考线) | P0 | 高 | +| B3 | rAF 驱动渲染循环 | P0 | 中 | +| B4 | 读写分离 (避免 layout thrash) | P0 | 中 | +| B5 | ResizeObserver/MutationObserver 同步 | P1 | 中 | +| B6 | 按需渲染 (非常驻 tick) | P1 | 低 | +| B7 | 智能对齐线与测距标注 | P2 | 高 | +| B8 | 拖拽残影动画 | P2 | 中 | +| B9 | Canvas DPR 适配 (高清屏支持) | P0 | 低 | + +### C. 属性面板 (Design/CSS) + +| ID | 功能点 | 优先级 | 复杂度 | +| --- | ------------------------------------------------ | ------ | ------ | +| C1 | Components 树 (DOM/组件层级) | P1 | 高 | +| C2 | Design 面板 - Position | P1 | 中 | +| C3 | Design 面板 - Layout (flex/grid) | P1 | 高 | +| C4 | Design 面板 - Size (W/H) | P1 | 中 | +| C5 | Design 面板 - Spacing (padding/margin) | P1 | 中 | +| C6 | Design 面板 - Typography | P1 | 中 | +| C7 | Design 面板 - Appearance (opacity/radius/border) | P1 | 中 | +| C8 | CSS 面板 - 样式来源追踪 | P2 | 高 | +| C9 | CSS 面板 - class 编辑 | P2 | 中 | +| C10 | Design System Tokens 集成 | P3 | 高 | + +### D. 直接操控 + +| ID | 功能点 | 优先级 | 复杂度 | +| --- | -------------------------------- | ------ | ------ | +| D1 | 拖拽重排 (move node) | P1 | 高 | +| D2 | 位置/尺寸手柄拖拽 | P2 | 高 | +| D3 | 智能吸附 (snap to edges/centers) | P2 | 高 | +| D4 | 文本直接编辑 (contentEditable) | P1 | 中 | +| D5 | Group/Stack 结构化操作 | P3 | 高 | + +### E. 变更事务系统(低优先级,先不考虑实现) + +| ID | 功能点 | 优先级 | 复杂度 | +| --- | ------------------------------- | ------ | ------ | +| E1 | Transaction 记录 (before/after) | P0 | 高 | +| E2 | Undo/Redo 栈 | P0 | 中 | +| E3 | 变更计数 UI (1 Edit) | P1 | 低 | +| E4 | Apply 失败自动回滚 | P1 | 中 | +| E5 | 拖拽过程合并为单事务 | P1 | 中 | + +### F. Apply 到代码同步链路 + +| ID | 功能点 | 优先级 | 复杂度 | +| --- | ------------------------------------------ | ------ | ------ | +| F1 | Payload 规范化 (locator/operation/context) | P0 | 高 | +| F2 | 框架调试信息定位 (React/Vue) | P0 | 中 | +| F3 | Selector 候选生成 | P1 | 高 | +| F4 | Agent Prompt 优化 | P1 | 中 | +| F5 | 执行结果反馈 UI | P1 | 中 | +| F6 | HMR 一致性校验 | P2 | 高 | + +### G. 工程化与兼容性 + +| ID | 功能点 | 优先级 | 复杂度 | +| --- | ---------------------- | ------ | ------ | +| G1 | 注入脚本 TypeScript 化 | P1 | 高 | +| G2 | 模块化架构 (分层清晰) | P0 | 高 | +| G3 | 核心逻辑单元测试 | P2 | 中 | +| G4 | 性能监控 (FPS/内存) | P2 | 中 | + +--- + +## 三、技术架构设计 + +### 3.1 整体分层架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Chrome Extension │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Background │ │ +│ │ - 注入控制 (toggleEditorInTab) │ │ +│ │ - Agent Prompt 构建 │ │ +│ │ - Native Server 通信 │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ↕ Message │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Inject Script (web-editor) │ │ +│ │ ┌────────────────────────────────────────────────┐ │ │ +│ │ │ Shadow DOM Host │ │ │ +│ │ │ ┌─────────────┐ ┌─────────────────────────┐ │ │ │ +│ │ │ │ Canvas │ │ UI Panel │ │ │ +│ │ │ │ Overlay │ │ (Toolbar/Sidebar/Tree) │ │ │ │ +│ │ │ │ (Renderer) │ │ (Vue 3) │ │ │ │ +│ │ │ └─────────────┘ └─────────────────────────┘ │ │ │ +│ │ └────────────────────────────────────────────────┘ │ │ +│ │ ┌────────────────────────────────────────────────┐ │ │ +│ │ │ Core Logic Layer │ │ │ +│ │ │ - InteractionEngine (事件/状态机) │ │ │ +│ │ │ - SelectionEngine (智能选中/指纹) │ │ │ +│ │ │ - TransactionManager (Undo/Redo) │ │ │ +│ │ │ - PayloadBuilder (上下文构建) │ │ │ +│ │ └────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↕ HTTP/SSE +┌─────────────────────────────────────────────────────────────┐ +│ Native Server │ +│ - Agent 执行引擎 (Codex/Claude) │ +│ - 代码定位与修改 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.2 核心模块划分 + +#### 3.2.1 渲染层 (Renderer) + +```typescript +// renderer/CanvasOverlay.ts +class CanvasOverlay { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private dirty: boolean = false; + + // 绘制元素 + drawSelectionBox(rect: DOMRect, style: BoxStyle): void; + drawHoverBox(rect: DOMRect): void; + drawAlignmentGuides(guides: Guide[]): void; + drawDistanceLabels(labels: DistanceLabel[]): void; + drawDragGhost(rect: DOMRect, opacity: number): void; + drawInsertionLine(position: InsertPosition): void; + + // 渲染控制 + markDirty(): void; + render(): void; // 由 rAF 调用 + startRenderLoop(): void; + stopRenderLoop(): void; +} +``` + +#### 3.2.2 交互层 (Interaction) + +```typescript +// interaction/InteractionEngine.ts +type EditorState = 'idle' | 'hovering' | 'selected' | 'dragging' | 'editing'; + +class InteractionEngine { + private state: EditorState = 'idle'; + private lastPointer: Point | null = null; + + // 事件处理 (只记录,不直接处理) + handlePointerMove(e: PointerEvent): void; + handlePointerDown(e: PointerEvent): void; + handlePointerUp(e: PointerEvent): void; + handleKeyDown(e: KeyboardEvent): void; + + // rAF 中调用的处理逻辑 + processFrame(): void { + // 1. 读取阶段: elementFromPoint, getBoundingClientRect + // 2. 计算阶段: 智能选中、拖拽位置 + // 3. 写入阶段: 更新 Canvas, 必要时更新 DOM + } +} +``` + +#### 3.2.3 选中层 (Selection) + +```typescript +// selection/SelectionEngine.ts +interface SelectionCandidate { + element: Element; + score: number; + reasons: string[]; +} + +class SelectionEngine { + // 智能选中 + findBestTarget(point: Point, modifiers: Modifiers): Element | null; + + // 候选评分 + private scoreCandidates(candidates: Element[]): SelectionCandidate[]; + + // 启发式规则 + private hasVisualBoundary(el: Element): boolean; + private isWrapperOnly(el: Element): boolean; + private getInteractivityScore(el: Element): number; + + // 结构指纹 + computeFingerprint(el: Element): string; + findSimilarElements(fingerprint: string): Element[]; + + // Shadow DOM 支持 + getDeepElementFromPoint(x: number, y: number): Element | null; +} +``` + +#### 3.2.4 事务层 (Transaction) + +```typescript +// transaction/TransactionManager.ts + +// 使用 Locator 而非 Element 引用,支持 HMR/DOM 变更后恢复 +interface ElementLocator { + selectors: string[]; // CSS selector 候选列表 + fingerprint: string; // 结构指纹 + debugSource?: DebugSource; // React/Vue 调试信息 + path: number[]; // DOM 树路径 (childIndex 序列) + // iframe/Shadow DOM 上下文 (Phase 2/4 需要) + frameChain?: string[]; // iframe selector 链 (从 top 到目标 frame) + shadowHostChain?: string[]; // Shadow DOM host selector 链 +} + +interface TransactionSnapshot { + locator: ElementLocator; + html?: string; // innerHTML 快照 (仅结构变更) + styles?: Record; // 变更的样式 + text?: string; // 文本内容 +} + +// move/structure 操作的详细数据结构 +interface MoveOperationData { + parentLocator: ElementLocator; // 目标父元素 + insertIndex: number; // 插入位置索引 + anchorLocator?: ElementLocator; // 锚点兄弟元素 (insertBefore 的参考) + anchorPosition: 'before' | 'after'; +} + +interface StructureOperationData { + action: 'wrap' | 'unwrap' | 'delete' | 'duplicate'; + wrapperTag?: string; // wrap 时的包装标签 + wrapperStyles?: Record; +} + +interface Transaction { + id: string; + type: 'style' | 'text' | 'move' | 'structure'; + targetLocator: ElementLocator; // 使用 Locator 而非 Element + before: TransactionSnapshot; + after: TransactionSnapshot; + // move/structure 操作的额外数据 + moveData?: MoveOperationData; + structureData?: StructureOperationData; + timestamp: number; + merged: boolean; // 是否已合并到上一个事务 +} + +class TransactionManager { + private undoStack: Transaction[] = []; + private redoStack: Transaction[] = []; + + // 事务操作 + begin(type: Transaction['type'], target: Element): TransactionHandle; + commit(handle: TransactionHandle): void; + rollback(handle: TransactionHandle): void; + + // Undo/Redo (通过 Locator 重新定位元素) + undo(): Transaction | null; + redo(): Transaction | null; + + // 元素定位 + private locateElement(locator: ElementLocator): Element | null; + + // 合并策略 (连续的同类型操作合并) + mergeIfContinuous(tx: Transaction): boolean; + + // 状态查询 + getPendingCount(): number; + getHistory(): Transaction[]; +} +``` + +#### 3.2.5 Payload 构建层 + +```typescript +// payload/PayloadBuilder.ts + +// Payload 字段限制 (避免消息过大) +const PAYLOAD_LIMITS = { + MAX_SELECTOR_COUNT: 5, + MAX_SKELETON_DEPTH: 3, + MAX_SKELETON_CHILDREN: 10, + MAX_SIBLING_ANCHORS: 3, + MAX_STYLE_PROPERTIES: 20, + MAX_TEXT_LENGTH: 500, + STYLE_WHITELIST: [ + 'display', + 'position', + 'width', + 'height', + 'margin', + 'padding', + 'color', + 'background', + 'font-size', + 'font-weight', + 'flex', + 'grid', + 'gap', + ], +}; + +interface EditorPayload { + version: '1.0'; // Schema 版本,便于后续升级 + locator: { + selectors: SelectorCandidate[]; + debugSource?: { file: string; line: number; column: number }; + fingerprint: ElementFingerprint; + }; + operation: { + type: 'update_text' | 'update_style' | 'move_node'; + before: any; + after: any; + }; + context: { + parentSkeleton: string; // 精简版 HTML 骨架 + siblingAnchors: string[]; // 兄弟节点锚点 + computedStyles: Record; // 白名单样式 + techStack: TechStackHint; + }; +} + +class PayloadBuilder { + build(transaction: Transaction): EditorPayload; + + // 定位信息 + private generateSelectors(el: Element): SelectorCandidate[]; + private extractDebugSource(el: Element): DebugSource | null; + private buildFingerprint(el: Element): ElementFingerprint; + + // 上下文 (带限制) + private extractParentSkeleton(el: Element, depth: number): string; + private extractSiblingAnchors(el: Element): string[]; + private getRelevantStyles(el: Element): Record; + + // 校验 + private validatePayload(payload: EditorPayload): boolean; +} +``` + +### 3.3 状态机设计 + +``` + ┌──────────────┐ + │ IDLE │ + └──────┬───────┘ + │ pointermove (enter element) + ▼ + ┌──────────────┐ + ┌─────│ HOVERING │─────┐ + │ └──────┬───────┘ │ + │ │ click │ pointermove (leave) + │ ▼ │ + │ ┌──────────────┐ │ + │ │ SELECTED │◄────┘ + │ └──────┬───────┘ + │ │ + ┌─────┼────────────┼────────────┐ + │ │ │ │ + │ pointerdown dblclick Escape/click outside + │ + drag │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ DRAGGING │ │ EDITING │ │ IDLE │ +└──────┬───────┘ └──────┬───────┘ └──────────────┘ + │ │ + │ pointerup │ blur/Enter/Escape + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ SELECTED │ │ SELECTED │ +│ (commit tx) │ │ (commit tx) │ +└──────────────┘ └──────────────┘ +``` + +### 3.4 消息协议设计 + +#### 3.4.1 注入脚本 ↔ Background + +```typescript +// 控制消息 +type ControlMessage = + | { action: 'web_editor_ping' } + | { action: 'web_editor_toggle' } + | { action: 'web_editor_start' } + | { action: 'web_editor_stop' }; + +// Apply 消息 +interface ApplyMessage { + type: 'web_editor_apply'; + payload: EditorPayload; + sessionId: string; +} + +// 结果回调 (F5 执行结果反馈链路) +interface ApplyResult { + success: boolean; + diff?: string; + error?: string; + suggestions?: string[]; +} + +// F5 执行结果订阅协议 +interface ApplyStatusUpdate { + type: 'web_editor_status'; + requestId: string; + status: 'pending' | 'locating' | 'applying' | 'completed' | 'failed' | 'timeout'; + progress?: number; // 0-100 + message?: string; // 状态描述 + result?: ApplyResult; // 完成时的结果 + timestamp: number; +} + +// Background 订阅 Agent SSE 事件后转发给 inject +// inject 通过 chrome.runtime.onMessage 接收状态更新 +``` + +#### 3.4.2 iframe 跨帧通信 + +```typescript +// Top → Child +interface FrameHitTestRequest { + type: 'web_editor_hit_test'; + x: number; // 相对于 iframe viewport + y: number; + requestId: string; +} + +// Child → Top +interface FrameHitTestResponse { + type: 'web_editor_hit_test_result'; + requestId: string; + element: SerializedElement | null; + rect: DOMRect | null; +} +``` + +--- + +## 四、任务拆分与执行计划 + +### Phase 0: 工程准备 (前置) + +**目标**: 确定构建方式,准备开发环境 + +| 序号 | 任务 | 预估工作量 | 依赖 | 功能点 | +| ---- | --------------------------------------------------- | ---------- | ---- | ------ | +| 0.1 | 确定注入脚本构建方式 (IIFE vs TS 构建) | 1h | - | G1 | +| 0.2 | 如选 TS 构建: 修改 WXT 配置支持 inject-scripts 编译 | 3h | 0.1 | G1 | +| 0.3 | 创建 web-editor-v2 目录结构和模块骨架 | 1h | 0.1 | G2 | + +### Phase 1: 基础架构 (P0) + +**目标**: 建立可工作的分层架构,替换现有实现 + +| 序号 | 任务 | 预估工作量 | 依赖 | 功能点 | +| ---- | ------------------------------------------ | ---------- | -------- | ---------------- | +| 1.1 | 创建新的模块化注入脚本结构 | 2h | Phase 0 | G2 | +| 1.2 | 实现 Shadow DOM 隔离宿主 | 2h | 1.1 | B1 | +| 1.3 | 实现 Canvas Overlay 基础渲染 (含 DPR 适配) | 4h | 1.2 | B2, B9 | +| 1.4 | 实现事件拦截与模式控制 | 2h | 1.2 | A0, A9 | +| 1.5 | 实现 rAF 驱动的交互引擎 | 4h | 1.3, 1.4 | B3, B4 | +| 1.6 | 实现智能选中引擎 (基础版含单击选中) | 4h | 1.5 | A1, A2, A3(基础) | +| 1.7 | 实现 Transaction Manager (基于 Locator) | 3h | 1.5 | E1, E2 | +| 1.8 | 实现 Payload Builder (带限制) | 3h | 1.7 | F1, F2 | +| 1.9 | 对接现有 Background 通信 | 2h | 1.8 | - | +| 1.10 | 基础 Toolbar UI (Apply/Undo/变更计数) | 3h | 1.9 | E3 | + +### Phase 2: 核心交互 (P1) + +**目标**: 实现 Figma 级的选中和编辑体验 + +| 序号 | 任务 | 预估工作量 | 依赖 | 功能点 | +| ---- | -------------------------------------- | ---------- | ------- | -------- | +| 2.1 | Shadow DOM 元素深度选择 (composedPath) | 3h | Phase 1 | A5 | +| 2.2 | 面包屑导航 UI | 2h | 2.1 | A4 | +| 2.3 | 修饰键交互 (Ctrl穿透/Shift上钻) | 2h | 2.1 | A3(高级) | +| 2.4 | 拖拽重排 - Canvas 部分 (ghost/插入线) | 4h | Phase 1 | B8 | +| 2.5 | 拖拽重排 - DOM 操作部分 | 3h | 2.4 | D1 | +| 2.6 | 拖拽重排 - 事务集成 (合并连续操作) | 2h | 2.5 | E5 | +| 2.7 | 文本直接编辑 (contentEditable) | 3h | Phase 1 | D4 | +| 2.8 | Observer 同步 (Resize/Mutation) | 2h | Phase 1 | B5 | +| 2.9 | Selector 候选生成器 | 3h | Phase 1 | F3 | +| 2.10 | Apply 失败自动回滚 | 2h | 1.7 | E4 | + +### Phase 3: 属性面板 (P1) + +**目标**: 实现右侧 Design/CSS 面板 + +| 序号 | 任务 | 预估工作量 | 依赖 | 功能点 | +| ---- | --------------------------------------------- | ---------- | ------- | ------ | +| 3.1 | 面板容器与 Tab 切换 (Vue) | 2h | Phase 1 | - | +| 3.2 | Components 树 (DOM 层级) | 4h | 3.1 | C1 | +| 3.3 | Position 控件 | 2h | 3.1 | C2 | +| 3.4 | Layout 控件 (display/flex/grid) | 4h | 3.1 | C3 | +| 3.5 | Size 控件 (W/H) | 2h | 3.1 | C4 | +| 3.6 | Spacing 控件 (padding/margin,支持拖拽 scrub) | 3h | 3.1 | C5 | +| 3.7 | Typography 控件 | 3h | 3.1 | C6 | +| 3.8 | Appearance 控件 (opacity/radius/border) | 3h | 3.1 | C7 | +| 3.9 | 即时 DOM 应用 + 事务集成 | 3h | 3.2-3.8 | - | +| 3.10 | 执行结果反馈 UI (requestId → 状态订阅) | 3h | 1.9 | F5 | +| 3.11 | Agent Prompt 优化 (利用新 payload) | 2h | 1.8 | F4 | + +### Phase 4: 高级功能 (P2) + +**目标**: 完善体验,增加高级能力 + +| 序号 | 任务 | 预估工作量 | 依赖 | 功能点 | +| ---- | ----------------------------------------------- | ---------- | ------- | ------ | +| 4.1 | iframe 支持 (allFrames 注入 + postMessage 桥接) | 6h | Phase 2 | A6 | +| 4.2 | 智能对齐线与吸附 | 4h | Phase 2 | D3 | +| 4.3 | 测距标注 | 3h | 4.2 | B7 | +| 4.4 | 组件实例识别 (结构指纹 + Worker 计算) | 4h | Phase 2 | A8 | +| 4.5 | 多选与框选 | 4h | Phase 2 | A7 | +| 4.6 | CSS 面板 - 样式来源追踪 | 4h | Phase 3 | C8 | +| 4.7 | CSS 面板 - class 编辑 | 3h | 4.6 | C9 | +| 4.8 | HMR 一致性校验 (依赖结果反馈) | 3h | 3.10 | F6 | +| 4.9 | 位置/尺寸手柄 | 4h | Phase 2 | D2 | +| 4.10 | 按需渲染优化 (静止时停止 tick) | 2h | Phase 1 | B6 | + +### Phase 5: 工程化与增强 (P2-P3) + +**目标**: 提升代码质量和可维护性,支持精准模式 + +| 序号 | 任务 | 预估工作量 | 依赖 | 功能点 | +| ---- | ----------------------------------------------- | ---------- | ------- | ------ | +| 5.1 | 注入脚本完全 TypeScript 化 (如 Phase 0 未完成) | 6h | Phase 3 | G1 | +| 5.2 | 核心逻辑单元测试 (scoring/fingerprint/geometry) | 4h | 5.1 | G3 | +| 5.3 | 性能监控集成 (FPS/内存) | 3h | Phase 4 | G4 | +| 5.4 | Design System Tokens 集成 | 4h | Phase 3 | C10 | +| 5.5 | Group/Stack 结构化操作 | 4h | Phase 2 | D5 | +| 5.6 | 文档与示例 | 3h | Phase 4 | - | + +### Phase 6: 精准模式 (P3,可选) + +**目标**: 支持 Vite 插件实现精准定位 + +| 序号 | 任务 | 预估工作量 | 依赖 | 功能点 | +| ---- | ------------------------------------------ | ---------- | ------- | ------ | +| 6.1 | Payload schema 增加 debugSource 版本化字段 | 2h | Phase 1 | - | +| 6.2 | Vite 插件开发 (注入 data-source-\*) | 6h | 6.1 | - | +| 6.3 | 插件安装文档与 npm 发布 | 2h | 6.2 | - | +| 6.4 | UI 检测 Vite 插件并提示安装 | 2h | 6.2 | - | + +--- + +## 4.1 功能点 → 任务追踪表 + +| 功能点 ID | 功能点描述 | 任务编号 | +| --------- | ------------------------------- | -------------------- | +| A0 | 事件拦截与编辑模式控制 | 1.4 | +| A1 | Hover 高亮 (60FPS) | 1.6 | +| A2 | 智能去噪选中 | 1.6 | +| A3 | 单击选中 + 修饰键 | 1.6(基础), 2.3(高级) | +| A4 | 面包屑导航 | 2.2 | +| A5 | Shadow DOM 内部元素支持 | 2.1 | +| A6 | iframe 内部元素支持 | 4.1 | +| A7 | 多选与框选 | 4.5 | +| A8 | 组件实例识别 | 4.4 | +| A9 | 编辑器自身元素过滤 | 1.4 | +| B1 | Shadow DOM 宿主隔离 | 1.2 | +| B2 | Canvas Overlay 层 | 1.3 | +| B3 | rAF 驱动渲染循环 | 1.5 | +| B4 | 读写分离 | 1.5 | +| B5 | ResizeObserver/MutationObserver | 2.8 | +| B6 | 按需渲染 | 4.10 | +| B7 | 智能对齐线与测距标注 | 4.3 | +| B8 | 拖拽残影动画 | 2.4 | +| B9 | Canvas DPR 适配 | 1.3 | +| C1 | Components 树 | 3.2 | +| C2 | Design 面板 - Position | 3.3 | +| C3 | Design 面板 - Layout | 3.4 | +| C4 | Design 面板 - Size | 3.5 | +| C5 | Design 面板 - Spacing | 3.6 | +| C6 | Design 面板 - Typography | 3.7 | +| C7 | Design 面板 - Appearance | 3.8 | +| C8 | CSS 面板 - 样式来源追踪 | 4.6 | +| C9 | CSS 面板 - class 编辑 | 4.7 | +| C10 | Design System Tokens 集成 | 5.4 | +| D1 | 拖拽重排 | 2.5 | +| D2 | 位置/尺寸手柄拖拽 | 4.9 | +| D3 | 智能吸附 | 4.2 | +| D4 | 文本直接编辑 | 2.7 | +| D5 | Group/Stack 结构化操作 | 5.5 | +| E1 | Transaction 记录 | 1.7 | +| E2 | Undo/Redo 栈 | 1.7 | +| E3 | 变更计数 UI | 1.10 | +| E4 | Apply 失败自动回滚 | 2.10 | +| E5 | 拖拽过程合并为单事务 | 2.6 | +| F1 | Payload 规范化 | 1.8 | +| F2 | 框架调试信息定位 | 1.8 | +| F3 | Selector 候选生成 | 2.9 | +| F4 | Agent Prompt 优化 | 3.11 | +| F5 | 执行结果反馈 UI | 3.10 | +| F6 | HMR 一致性校验 | 4.8 | +| G1 | 注入脚本 TypeScript 化 | 0.2, 5.1 | +| G2 | 模块化架构 | 0.3, 1.1 | +| G3 | 核心逻辑单元测试 | 5.2 | +| G4 | 性能监控 | 5.3 | + +--- + +## 五、可复用资源 + +### 5.1 来自 element-marker + +- Shadow DOM 隔离模式: `element-marker.js:833` +- 深度元素选择 (composedPath): `element-marker.js:1714` +- Selector 唯一性校验: `element-marker.js:1479` +- 高亮器移动逻辑: `element-marker.js:1585` + +### 5.2 来自 accessibility-tree-helper + +- 跨 frame 桥接模式: `accessibility-tree-helper.js:1013` +- DOM 遍历上限控制: `accessibility-tree-helper.js:10` + +### 5.3 来自现有 web-editor + +- 视觉启发式选中 (部分): `web-editor.js:122` +- React/Vue 调试信息提取: `web-editor.js:62`, `web-editor.js:96` +- 技术栈检测: `web-editor.js:39` +- Background 通信协议: `background/web-editor/index.ts` + +--- + +## 六、风险与缓解 + +| 风险 | 影响 | 缓解措施 | +| ---------------------- | ---- | ------------------------------------- | +| 复杂页面性能问题 | 高 | rAF 节流、按需渲染、Web Worker 计算 | +| Shadow DOM closed mode | 中 | 降级到 host 级别选中,给出提示 | +| 跨域 iframe | 高 | 检测并提示"无法编辑跨域内容" | +| Agent 定位失败 | 中 | 多候选 selector、LLM rerank、手动确认 | +| 注入脚本包体积 | 中 | 按需加载、代码分割 | + +--- + +## 七、验收标准 + +### Phase 1 验收 + +- [ ] 新架构可正常注入和卸载 +- [ ] Hover 高亮流畅 (60FPS) +- [ ] 点击选中功能正常 +- [ ] Undo/Redo 可用 +- [ ] Apply to Code 可触发 Agent + +### Phase 2 验收 + +- [ ] Shadow DOM 内元素可选中 +- [ ] 拖拽重排功能完整 +- [ ] 文本可直接编辑 +- [ ] 交互响应 < 16ms + +### Phase 3 验收 + +- [ ] 属性面板全部控件可用 +- [ ] 样式修改即时生效 +- [ ] Components 树与选中联动 + +### 最终验收 + +- [ ] 对标 Cursor Visual Editor 截图功能 +- [ ] 复杂页面 (10000+ 节点) 可用 +- [ ] 主流框架 (React/Vue/Next/Nuxt) 兼容 +- [ ] 无明显样式污染 + +--- + +## 八、实现进度记录 + +### Phase 0: 工程准备 ✅ 完成 + +**完成时间**: 2024-12 + +**决策记录**: + +- 采用 WXT 的 `defineUnlistedScript` 进行 TypeScript 构建 +- 输出为独立的 `web-editor-v2.js` 文件(当前 47.75KB) +- 使用 V2 版本化 action 名称(后缀 `_v2`)实现 V1/V2 共存 + +**创建的文件**: + +- `common/web-editor-types.ts` - 共享类型定义,包含 `WEB_EDITOR_V2_ACTIONS`、`WebEditorV2Api` 等 + +--- + +### Phase 1.1-1.2: 模块化结构与 Shadow DOM 隔离 ✅ 完成 + +**目录结构**: + +``` +entrypoints/ +├── web-editor-v2.ts # 入口点 (defineUnlistedScript) +└── web-editor-v2/ + ├── constants.ts # 常量配置 + ├── utils/ + │ └── disposables.ts # 资源清理工具 (Disposer 类) + ├── ui/ + │ └── shadow-host.ts # Shadow DOM 隔离宿主 + ├── core/ + │ ├── editor.ts # 主协调器 (生命周期管理) + │ ├── message-listener.ts # Background 通信 + │ ├── event-controller.ts # 事件拦截与模式控制 + │ └── position-tracker.ts # 滚动/resize 位置同步 + ├── overlay/ + │ └── canvas-overlay.ts # Canvas 渲染层 + └── selection/ + └── selection-engine.ts # 智能选中引擎 +``` + +**关键实现**: + +#### `constants.ts` + +- `WEB_EDITOR_V2_VERSION = 2` +- `WEB_EDITOR_V2_HOST_ID = '__mcp_web_editor_v2_host__'` +- `WEB_EDITOR_V2_Z_INDEX = 2147483647` (最大 z-index) +- 颜色定义: hover (#3b82f6), selected (#8b5cf6) + +#### `utils/disposables.ts` - Disposer 类 + +```typescript +class Disposer { + add(dispose: DisposeFn): void; // 注册清理函数 + listen(target, type, listener, options); // 自动移除的事件监听 + observeResize(target, callback); // 自动断开的 ResizeObserver + observeMutation(target, callback); // 自动断开的 MutationObserver + requestAnimationFrame(callback); // 自动取消的 rAF + dispose(): void; // LIFO 顺序清理 + get isDisposed(): boolean; +} +``` + +#### `ui/shadow-host.ts` - Shadow DOM 宿主 + +- 创建固定定位的 host 元素,挂载到 `document.documentElement` +- 使用 `attachShadow({ mode: 'open' })` 创建 Shadow Root +- 提供 `overlayRoot`(用于 Canvas)和 `uiRoot`(用于 UI 面板) +- 事件隔离:阻止 UI 事件冒泡到页面(pointer/mouse/keyboard/touch/focus 等) +- 提供 `isOverlayElement(node)` 判断节点是否属于编辑器 +- 内置简单的状态面板 UI(标题 + Exit 按钮 + 状态指示) + +--- + +### Phase 1.3: Canvas Overlay 基础渲染 ✅ 完成 + +**文件**: `overlay/canvas-overlay.ts` + +**功能**: + +- DPR 感知渲染(`devicePixelRatio` 适配高清屏) +- markDirty/render 模式实现 rAF 合并渲染 +- ResizeObserver 自动调整画布尺寸 +- 绘制 hover 矩形(蓝色虚线 + 8% 填充) +- 绘制 selection 矩形(紫色实线 + 12% 填充) +- 像素对齐实现清晰线条 + +**接口**: + +```typescript +interface CanvasOverlay { + canvas: HTMLCanvasElement; + markDirty(): void; // 标记需要重绘 + render(): void; // 立即渲染 + clear(): void; // 清除所有 + setHoverRect(rect: ViewportRect | null); // 设置 hover 框 + setSelectionRect(rect: ViewportRect | null); // 设置选中框 + dispose(): void; +} +``` + +--- + +### Phase 1.4: 事件拦截与模式控制 ✅ 完成 + +**文件**: `core/event-controller.ts` + +**功能**: + +- Capture 阶段拦截 document 级事件 +- 两种模式状态机: `hover` ↔ `selecting` +- 支持 PointerEvents(现代浏览器)和 MouseEvents(兼容) +- Touch 事件拦截(移动端) +- ESC 键取消选中 +- rAF 节流 hover 更新(避免高频 `elementFromPoint` 导致性能问题) +- 事件回调: `onHover(element)`, `onSelect(element, modifiers)`, `onDeselect()` +- 可插拔的智能选中: `findTargetForSelect` 选项 + +**事件拦截列表**: + +- pointer: move, down, up, cancel, over, out, enter, leave +- mouse: move, down, up, click, dblclick, contextmenu, auxclick, over, out, enter, leave +- keyboard: down, up, press +- touch: start, move, end, cancel + +**修饰键支持**: + +```typescript +interface EventModifiers { + alt: boolean; // Alt + Click 触发上钻 + shift: boolean; // 预留多选 + ctrl: boolean; + meta: boolean; +} +``` + +--- + +### Phase 1.5: rAF 驱动的交互引擎 ✅ 完成 + +**文件**: `core/position-tracker.ts` + +**功能**: + +- 监听 `window.scroll`、`window.resize` 和 `document.scroll`(capture) +- rAF 合并位置更新请求 +- 检测元素是否仍在 DOM 中(`isConnected`) +- 子像素容差过滤(`RECT_EPSILON = 0.5`)避免抖动 +- 只在位置实际变化时触发回调 + +**接口**: + +```typescript +interface PositionTracker { + setHoverElement(element: Element | null): void; + setSelectionElement(element: Element | null): void; + forceUpdate(): void; // 立即同步更新 + dispose(): void; +} +``` + +**性能优化**: + +- 在 `editor.ts` 中设置元素后调用 `forceUpdate()` 避免额外 rAF 延迟 +- 在位置更新回调中调用 `canvasOverlay.render()` 合并到同一帧 + +--- + +### Phase 1.6: 智能选中引擎 ✅ 完成 + +**文件**: `selection/selection-engine.ts` + +**评分系统** (正分优先,负分降级): + +| 类别 | 规则 | 分数 | +| ------------ | --------------------------------------- | ---- | +| **交互性** | `) -> 进入第 2 步。 + +如果没有匹配(例如文本是变量 {t('submit_order')}) -> 进入第 3 步。 + +2. 结构化模糊匹配 (LLM Rerank) + +当 Grep 找到多个候选文件时,让 LLM 来判断。 + +Prompt: + +"我正在寻找生成这段 DOM 的 React/Vue 组件。 +DOM 结构是:。 +我通过关键词搜索找到了以下 3 个候选文件:[FileA, FileB, FileC]。 +请分析这 3 个文件的源码,判断哪一个最符合上述 DOM 结构。" + +LLM 擅长理解代码逻辑,能识别出条件渲染、循环等结构,从而过滤出正确文件。 + +3. 纯语义搜索 (Vector Search / Embedding) - 兜底方案 + +如果文本是动态的(API返回的数据),Grep 搜不到。 + +策略:在 Server 启动时,或者按需对代码库建立简单的向量索引(可以用 OpenAI Embeddings 或本地模型)。 + +执行:将 DOM 结构描述转化为向量,在代码库中搜索相似度最高的代码块。 + +代价:这比较重,建议作为最后的兜底手段。 + +第三步:AI 修改与回写 (The Agent) + +一旦定位到文件(比如 src/components/LoginForm.tsx),流程就回到了具体的修改上。 + +在“通用模式”下,你不能完全依赖写死的 AST 逻辑(因为你不知道用户是用 CSS Modules, Tailwind, 还是 Styled Components)。这里必须重度依赖 AI。 + +Prompt 设计思路: + +Role: Senior Frontend Engineer +Task: User wants to change the visual style of a specific element. +Context: + +Target File: src/components/LoginForm.tsx (File Content Included) + +Target Element Fingerprint: inside a
. + +User Instruction: "Change background color to blue." + +Requirements: + +Analyze the code to find the exact JSX/Template element. + +Determine the styling strategy used in this file (Tailwind? Inline styles? CSS file import?). + +If Tailwind: Add bg-blue-500. + +If Inline: Add style={{ backgroundColor: 'blue' }}. + +If CSS Modules: You might need to edit the corresponding .module.css file (Advanced). + +Output: Return the full modified code (or a diff patch). + +体验超越 Cursor 的关键点 + +Cursor 目前在网页端的直接编辑功能其实并不完美(它主要还是在 IDE 里强),你可以通过以下两点在体验上超越它: + +1. “渐进式增强”架构 (Progressive Enhancement) + +这正是你提到的思路。 + +Level 1 (纯通用):用户只装浏览器插件 + 启动本地 Server。依靠 AI 搜索定位。优点:即插即用。缺点:对于完全动态的列表可能定位稍慢,偶尔会有 5% 的幻觉。 + +Level 2 (精准模式):提示用户“检测到您使用的是 Vite,是否安装我们的开发插件以获得 100% 精准度和毫秒级响应?” + +一旦安装 Vite 插件,自动降级为读取 data-source 属性,直接跳过复杂的 AI 搜索步骤,体验瞬间起飞。 + +这种“无缝升级”的体验会非常棒。 + +2. 交互反馈的即时性 (Optimistic UI) + +因为通用模式下 AI 搜索+修改需要几秒钟,用户等待会焦虑。 + +Visual Editor:在用户拖拽/改色时,直接在浏览器 DOM 上应用 CSS 变更,让用户立刻看到效果(就像 Chrome DevTools)。 + +后台处理:同时后台 Agent 默默地去改代码。 + +状态同步:当 Agent 改完代码,HMR 触发页面刷新,如果新页面和刚才用户改的一样,用户是无感知的;如果不一样,给予提示。 + +总结方案的可行性 +步骤 任务 纯通用模式 (AI Search) 增强模式 (Build Injector) +定位 找到源码位置 难点:靠 Grep + LLM 分析上下文。耗时 1-3s。 秒杀:直接读属性。耗时 0s。 +修改 生成新代码 依赖 LLM 识别代码风格并重写。 结合 AST + LLM,更精准。 +可靠性 准确率 80%-90% (动态内容容易丢) 99.9% + +结论: +完全可行。Perplexity 风格的搜索 + Copilot 风格的修改 = 你的通用模式。 +先做通用模式能让你覆盖所有 Web 项目(Next.js, Vue, Svelte, 甚至原生 HTML),这对于获取早期用户至关重要。等用户觉得好用但偶尔有点慢时,推给他们 Vite 插件,他们会欣然接受。 + +q: 这个可视化编辑器的本质就是给ai组装好它修改所需要的上下文,至于后续它怎么修改你不用考虑,现在就只关注这个可视化编辑器怎么做,比如我看它还能拖拽改变布局等,这个应该怎么做呢,浏览器插件能做到吗 +a: 这完全可以实现,而且这就是浏览器插件(Chrome Extension Content Script)最擅长的领域。 + +在这个场景下,你的浏览器插件其实就是一个寄生在当前网页上的“Webflow”。 + +不考虑后端的 AI 如何修改代码,单就前端可视化交互(选择、拖拽、即时反馈)而言,核心技术实现可以拆解为以下几个模块。这套方案在业界被称为 "Overlay Editor"(覆盖式编辑器) 模式。 + +1. 基础架构:Shadow DOM 隔离 (The Stage) + +浏览器插件最大的坑是样式污染(你的按钮样式影响了宿主网站,或者宿主网站的全局 CSS 搞乱了你的编辑器 UI)。 + +解决方案: +在页面 的最后插入一个宿主节点,并开启 Shadow DOM。你的所有 UI(高亮框、侧边栏、工具条)都渲染在这个 Shadow Root 里。 + +code +JavaScript +download +content_copy +expand_less +// content-script.js +const host = document.createElement('div'); +host.id = 'cursor-clone-root'; +document.body.appendChild(host); + +const shadow = host.attachShadow({ mode: 'open' }); +// 在这里注入你的 React/Vue 应用 +ReactDOM.createRoot(shadow).render(); 2. 核心交互:高亮与选中 (The Inspector) + +这部分负责让用户知道自己选中了哪个 DOM 元素。 + +技术实现: + +事件拦截:在 document 上监听 mouseover 和 click。开启一个“审查模式”,通过 e.stopPropagation() 和 e.preventDefault() 阻止网页原本的交互(比如点击链接跳转)。 + +坐标映射: + +当鼠标划过 targetDOM 时,调用 targetDOM.getBoundingClientRect() 获取它的位置(top, left, width, height)。 + +在你的 Shadow DOM 里,绘制一个透明背景、蓝色边框的 div,通过 position: fixed 覆盖在那个坐标上。 + +滚动同步: + +网页滚动时,坐标会变。你需要监听 scroll 事件或使用 requestAnimationFrame 持续更新高亮框的位置,保证它像吸铁石一样吸附在元素上。 + +3. 难点攻克:拖拽改变布局 (Drag & Drop Reordering) + +这是你提到的最酷的功能(把一个 Header 拖到 Div 下面)。因为你操作的是原生 DOM,而不是你自己的 React 组件,所以不能直接用 react-dnd 这种库,得手动实现。 + +实现逻辑: + +抓取 (Drag Start): + +用户按住高亮框。 + +前端设置目标 DOM 样式 opacity: 0.5(视觉反馈)。 + +关键点:在鼠标位置生成一个该元素的“截图”或“克隆体”(Ghost Element),跟随鼠标移动。 + +探测投放区 (Hit Testing): + +当鼠标移动时,隐藏“克隆体”(pointer-events: none),利用 document.elementFromPoint(x, y) 实时获取鼠标底下的元素(即:潜在的父容器或兄弟节点)。 + +计算插入点 (Insertion Logic): + +这是最复杂的算法部分。 + +假设鼠标在元素 B 上。 + +计算鼠标在 B 的上半部分还是下半部分(如果是垂直布局)。 + +视觉反馈:在 Shadow DOM 里画一条蓝色的横线(Indicator),提示用户“松手后会插在这里”。 + +即时预览 (Optimistic UI): + +为了体验像 Cursor 视频里那样丝滑,不要等 AI。 + +直接调用原生 DOM API:parentNode.insertBefore(draggingNode, referenceNode)。 + +用户会看到元素真的被挪动了。这时候 DOM 结构已经变了。 + +生成上下文给 AI: + +记录动作:{ action: "move", targetId: "...", newParentId: "...", prevSiblingId: "..." }。 + +把这个“意图包”发给 Agent。 + +4. 样式编辑与即时反馈 (Style Editor) + +右边栏的样式修改(改颜色、改字体、Flex 布局)实现相对简单。 + +流程: + +读取:选中元素时,使用 window.getComputedStyle(el) 读取当前生效的所有样式,回填到右侧面板的表单里。 + +写入: + +当用户拖拽颜色滑块时。 + +直接操作 DOM:element.style.backgroundColor = newValue。 + +这就是为什么视频里能实时预览。你只是在改内联样式(Inline Style)。 + +发送: + +用户松手后,打包数据:{ originalColor: "red", newColor: "blue", computedStyle: "..." } 发给 AI。 + +5. 文本编辑 (Text Editing) + +视频里应该也有直接改文字的功能。 + +实现: + +双击高亮框 -> 给目标 DOM 元素设置 contentEditable="true"。 + +用户像在 Word 里一样打字。 + +blur 事件触发时 -> 取消 contentEditable -> 提取 innerText -> 告诉 AI 文案变了。 + +总结:数据结构设计 (The Context Payload) + +既然你的核心是“给 AI 组装上下文”,那么你的插件最终产出的产品就是一个 JSON 包。 + +当用户完成一次拖拽操作后,你的插件应该生成这样的数据发给后端: + +code +JSON +download +content_copy +expand_less +{ +"task": "layout_modification", +"target_element": { +"tag": "div", +"classes": ["card", "p-4"], +"inner_text": "Feature Title", +"context_html": "
...
" // 拖拽前的快照 +}, +"operation": { +"type": "move_node", +"destination": { +"parent_fingerprint": { "tag": "section", "id": "features-grid" }, +"previous_sibling_fingerprint": { "tag": "div", "classes": ["card", "first"] }, +"position": "after" +} +}, +"visual_preview_result": { +// 告诉AI,现在的DOM变成了什么样,辅助它理解 +"new_parent_html": "
...
" +} +} +推荐使用的 Web API + +要实现这个编辑器,你需要熟练掌握以下原生 API: + +element.getBoundingClientRect():获取位置,绘制高亮框的核心。 + +window.getComputedStyle(element):获取真实的样式值(即便是通过 class 设置的)。 + +MutationObserver:监听 DOM 变化。如果网页里有动画导致元素位置变了,你的高亮框得跟着变,否则会错位。 + +element.contains(otherElement):判断拖拽时是否把父元素拖到了子元素里(非法操作)。 + +结论 + +浏览器插件完全能做到。 +这就是一个运行在 Content Script 里的 React 应用,通过 Shadow DOM 隔离样式,通过 DOM API 操作宿主页面的节点,并最终输出一份“操作日志”给 AI。体验流畅度的关键在于在发给 AI 之前,先在浏览器里用 JS 把效果模拟出来(Optimistic UI)。 + +q: 其实这里我觉还可以深挖一下,做这么一个可视化编辑器,一个是怎么精准识别出各个区块和元素,一个是对于复杂的元素很多的网页,性能要过得去,还有一个是对于一些像web component、shadowdom之类的网页是否能支持 + +还有,业界有成熟的方案吗 +a: 这是一个非常专业且切中痛点的问题。做 demo 容易,但要在一个像淘宝、Youtube 或企业级 SaaS 这种 DOM 节点数以万计、且技术栈复杂的页面上跑得丝滑,确实需要深挖。 + +以下是针对精准识别、性能优化、现代 Web 特性支持的深度技术方案,以及业界成熟方案的调研。 + +一、 难点深挖 1:如何“精准”识别区块(智能选择算法) + +痛点:网页里充斥着大量的“包装容器”(Wrapper Divs)。用户想改一个卡片,鼠标放上去往往选中了卡片外面那个用来做 Padding 的 div,或者是里面那个用来做 Flex 布局的 div,而不是用户认知中的“卡片”。 + +解决方案:启发式去噪与智能层级穿越 + +尺寸去噪 (Size Heuristics): + +忽略透明且全尺寸的容器:如果父元素和子元素的大小完全一致(rect 相同),且父元素没有背景色、边框、阴影,那么在默认 Hover 时,直接透传选中子元素。 + +忽略布局空壳:忽略 display: contents 或长宽为 0 的元素。 + +交互式层级穿越 (Interactive Drilling): + +模仿 Figma/Sketch 的逻辑: + +默认 (Hover):选中最深层的“叶子节点”或者有视觉特征的容器(有背景、边框)。 + +按住 Cmd/Ctrl:强制穿透,选中鼠标下绝对最深层的节点(哪怕它是透明的)。 + +按住 Shift + Click:选中当前元素的父级。 + +双击:进入组件内部(如果识别为组件)。 + +视觉特征加权 (Visual Weighting): + +在计算“应该高亮谁”时,读取 getComputedStyle。 + +如果一个元素有 backgroundColor !== 'rgba(0,0,0,0)' 或 borderWidth > 0 或 boxShadow,它的选中权重增加。优先高亮这些“肉眼可见”的元素,而不是看不见的布局框。 + +二、 难点深挖 2:复杂页面的性能优化(FPS 维持在 60) + +痛点:在有 10,000+ 节点的页面上,监听 mousemove 并实时计算 getBoundingClientRect (重排重绘开销大) 会导致鼠标移动卡顿,高亮框跟不上。 + +解决方案:层级分离与计算节流 + +事件委托与节流 (Event Delegation & Throttling): + +不要给每个 DOM 绑事件。只在 document 上绑一个 mousemove。 + +使用 requestAnimationFrame (rAF) 而不是 throttle。在每一帧的开始计算一次高亮位置。如果鼠标移动非常快,中间过程的计算直接丢弃,只渲染每一帧最后的位置。 + +避免 React 重绘 (Bypass React Reconciliation): + +高亮框(Overlay)的位置更新极其频繁。 + +不要用 React State (setState({ x, y })) 来更新高亮框位置,这会触发 React 的 Diff 算法,太慢。 + +直接操作 DOM:使用 useRef 获取 Overlay 的 DOM 节点,在 mousemove 回调里直接设置 overlayRef.current.style.transform = translate(${x}px, ${y}px)。这种“脏操作”在高性能交互场景是必须的。 + +合成层提升 (Layer Promotion): + +给你的高亮框 Overlay 加上 will-change: transform, width, height。 + +这会让浏览器把这个高亮框放在一个独立的 GPU 图层上。当它移动大小时,不会触发宿主页面的重绘(Repaint),只会触发合成(Composite),性能提升巨大。 + +指针事件穿透 (Pointer Events): + +你的 Overlay 必须设置 pointer-events: none;。 + +否则,当高亮框盖住元素后,你的鼠标就变成悬停在高亮框上了,底下的 elementFromPoint 就失效了,导致高亮框闪烁(Z-fighting)。 + +三、 难点深挖 3:Shadow DOM 与 Web Components 支持 + +痛点:document.querySelector 和常规的 elementFromPoint 具有“封装边界”,它们看不见 Shadow Root 内部的元素。如果宿主页面用了 Web Components(比如 Lit, Shoelace 库),你的编辑器会“瞎”掉,只能选中最外层的壳。 + +解决方案:递归穿透与 Composed Path + +递归版 elementFromPoint: +标准 API 遇到 Shadow DOM 会停在 Host 节点。你需要自己写一个递归查找函数: + +code +JavaScript +download +content_copy +expand_less +function getDeepElementFromPoint(x, y) { +let el = document.elementFromPoint(x, y); + +// 循环递归:如果当前命中的元素有 shadowRoot,就钻进去找 +while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) { +const shadowEl = el.shadowRoot.elementFromPoint(x, y); +if (!shadowEl || shadowEl === el) break; +el = shadowEl; +} +return el; +} + +这个算法是支持所有现代 Shadow DOM 页面的核心。 + +事件冒泡的 composedPath: +在点击事件中,使用 event.composedPath() 获取完整的冒泡路径。这个数组包含了从 Shadow DOM 内部一直到 Document 的所有节点。利用这个路径,你可以正确地构建“面包屑导航”,让用户知道自己是在 my-card (Shadow) > div.header > span 里面。 + +样式注入难题: + +如果你要在 Shadow DOM 内部显示高亮框,通常很难(因为你不能把你的 DOM 插到别人的 Shadow Root 里)。 + +策略:依然把高亮框放在最外层(你的编辑器层)。使用 getBoundingClientRect 计算 Shadow DOM 内部元素的全局坐标。这通常能工作,因为坐标系是全局的。 + +四、 业界成熟方案调研 + +既然要做“通用可视化编辑器”,不需要闭门造车,可以参考以下成熟的开源项目或商业产品: + +1. 开源框架 (可以直接参考代码) + +GrapesJS (最强参考): + +地位:Web 领域最成熟的开源可视化构建框架。 + +参考点:它的 Select, Drag, Drop, Style Manager 实现非常完整。它是基于 Canvas(iframe)隔离的,但逻辑和 Overlay Editor 是一样的。 + +技术栈:Backbone (老),但架构设计(Component Model, CSS Composer)非常值得学习。 + +Builder.io / Mitosis: + +地位:目前 AI + Visual Coding 的领头羊。 + +Visual Copilot:他们实现了将 Figma 直接转代码,且支持反向编辑。 + +开源部分:Qwik 框架和 Partytown 也是他们出的,他们在 DOM 序列化和性能优化上有极深的研究。 + +Puck (Puck Editor): + +地位:一个基于 React 的现代可视化编辑器组件。 + +特点:非常轻量,专门用于 React 组件的拖拽和配置。它的 DropZone 实现逻辑非常有参考价值。 + +2. 商业标杆 (体验对标) + +Webflow: + +可视化的天花板。它的“DOM 树导航”、“Style 面板”的交互细节(如拖拽调整 Margin 数值)是教科书级别的。 + +Framer: + +React 友好,体验极佳。可以参考它的“智能布局”推断(从 Absolute 推断为 Stack/Flex)。 + +VisBug (Chrome Extension): + +必看! Google Chrome 团队推出的开源插件。 + +功能:它就是一个纯前端的可视化编辑器插件,可以 hover, measure, move, edit text。 + +价值:完全开源。它的代码直接解决了你问的所有问题(如何高亮、如何拖拽、如何处理文字编辑)。这是你最直接的技术起步模板。 + +五、 总结建议 + +如果你现在要落地,我建议的技术路线图: + +第一周:克隆 VisBug 的核心逻辑。 + +去 GitHub 搜 VisBug,看它怎么用 document.elementFromPoint 和 getBoundingClientRect 做的高亮和选中。 + +这解决了 80% 的前端交互问题。 + +第二周:加入 Shadow DOM 递归查找。 + +把上面的递归函数加上,解决兼容性问题。 + +第三周:性能调优。 + +上 requestAnimationFrame 和 will-change,确保在复杂页面不卡。 + +第四周:对接你的 AI 逻辑。 + +把你第一周做出的 payload 喂给后端。 + +VisBug 是最接近你“可视化编辑器”形态的现成参考,强烈建议先去体验并阅读它的源码。 + +q: 结合VisBug有办法做到比这些商业标杆和我发给你的视频里的体验更好吗,我想做到跟商业级的应用对标的体验,把这个可视化编辑器结合我本身提供的ai能力,就相当于做了一个浏览器端的前端工作台了 +a: 这绝对可以实现。VisBug 只是一个“基础原型”,它证明了技术可行性,但在交互细节、视觉美感和智能化程度上,距离 Webflow、Framer 或 Cursor 还有很大差距。 + +要想把浏览器插件做成**“商业级前端工作台”,并超越现有方案,你需要从单纯的“DOM 修改器”进化为“逆向设计系统引擎”**。 + +以下是具体的差异化打法和技术升级方案,旨在打造超越 Cursor 视频体验的下一代产品: + +一、 体验升级:从“操作 DOM”到“操作组件” + +痛点:VisBug 或 Chrome DevTools 是“只有上帝视角的”,它们看到的是冰冷的
。用户想要的是“选中这个商品卡片”,结果只选中了卡片里的文字或外面的容器,体验很碎。 + +超越方案:智能语义选择 (Semantic Selection) + +“磁吸”层级算法 (Magnetic Selection): + +原理:不要只是 elementFromPoint。当鼠标划过元素时,计算它的“视觉权重”和“逻辑完整性”。 + +实现: + +如果一个 div 只是用来做 padding 的(没有背景、边框、文字),高亮框自动吸附到它内部有内容的子元素上。 + +如果鼠标放在一个 img 上,但这个 img 是 Card 的一部分,高亮框应提供一个明显的“父级胶囊”(类似 Figma 点击后显示父级名称),允许一键升级选中父级。 + +体验:用户会感觉你在猜他的心思,而不是笨拙地选 DOM。 + +组件边界识别 (Component Bounds): + +技术:利用 AI 或启发式算法识别重复结构。 + +场景:当鼠标放在一个列表项上时,自动识别出这是一个 List,并高亮所有兄弟节点(Sibling Instances)。 + +超越点:用户修改其中一个的样式,插件询问:“应用到所有类似组件?”(Apply to all instances)。这是 Cursor 视频里没有展示的高级功能。 + +二、 交互升级:Figma 级的视觉反馈 + +痛点:普通网页拖拽时,元素乱跳,甚至会破坏布局。VisBug 的拖拽非常原始。 + +超越方案:设计工具级的平滑交互 + +智能对齐线 (Smart Guides): + +实现:参考 Figma/Sketch。当你拖动元素或调整 Margin 时,实时计算并显示到周围元素的距离(像素值)。 + +技术:在 Shadow DOM 的 Canvas 层(或 SVG 层)绘制。不要用 DOM 元素画线,性能太差。用 Canvas 覆盖在最上层绘制红线和数字,性能极佳。 + +平滑过渡动画 (Motion Layout): + +实现:引入 framer-motion 或 FLIP (First, Last, Invert, Play) 动画技术。 + +场景:当你把一个元素从左拖到右,其他受影响的兄弟元素不应该“瞬间跳变”,而应该平滑滑动到新位置。 + +体验:这种丝滑感是区分“工具”和“产品”的关键。 + +自动布局推断 (Auto-Layout Inference): + +场景:用户想把两个分散的按钮变成一组。 + +操作:用户多选这两个按钮 -> 右键 -> “Group / Stack”。 + +逻辑:编辑器自动插入一个 div,设置为 display: flex; gap: 10px,把两个按钮包进去。 + +超越点:你不仅仅是改属性,你是在重构 DOM 结构。 + +三、 性能升级:60FPS 的极致流畅 + +痛点:在淘宝、小红书这种复杂页面,DOM 树极其庞大,常规插件会卡顿。 + +超越方案:GPU 渲染与虚拟化 + +Canvas 覆盖层 (The Canvas Overlay): + +彻底抛弃 DOM 高亮框。 + +VisBug 还是在用 DOM 做高亮。你应该在 Shadow DOM 里放一个全屏的 。 + +所有的选中框、对齐线、距离标注、拖拽残影,全部用 WebGL 或 2D Canvas 绘制。 + +优势:Canvas 绘制 1000 个矩形也就是一瞬间的事,完全不触发布局重排(Reflow),性能是 DOM 的几十倍。 + +非阻塞计算 (Web Worker): + +对于“组件识别”、“相似元素查找”这种耗时逻辑,放到 Web Worker 里跑,不要阻塞主线程的 UI 渲染。 + +四、 AI 能力融合:意图优先 (Intent-First) + +痛点:Cursor 还要用户去打字说“居中”。 + +超越方案:上下文预判 (Contextual Actions) + +视觉重构 (Visual Refactoring): + +功能:选中一个丑陋的表格。 + +Magic Button:点击“AI 美化”。 + +流程:插件截取该区域的 HTML + 截图 -> 发给多模态 AI -> AI 返回新的 Tailwind 类名组合 -> 实时应用。 + +体验:用户不需要懂 CSS,甚至不需要说话。 + +从图片生成代码 (Image to Code): + +功能:用户觉得现在的 Header 不好看,从 Dribbble 截图了一个好看的 Header。 + +操作:直接把截图粘贴到浏览器页面上。 + +逻辑:你的编辑器捕获粘贴事件 -> AI 解析截图 -> 生成对应的 HTML/CSS -> 插入到当前页面。 + +超越点:直接把别人的设计“贴”进自己的代码库。 + +智能文案替换: + +选中一段 Lorem Ipsum 占位符 -> 点击“Fill Content” -> AI 根据页面上下文(比如是旅游网站)自动填入真实的旅游文案。 + +五、 技术落地:构建“前端工作台”的核心模块 + +要实现上述超越级体验,你的技术架构需要包含以下三个高级模块: + +1. The Rendering Engine (渲染引擎) + +技术栈:Pixi.js 或 Konva.js (用于 Canvas 绘制 UI)。 + +职责:负责绘制所有的高亮、标尺、对齐线。不操作 DOM,只“画”在上面。 + +2. The Semantic Engine (语义引擎) + +职责:理解 DOM。 + +算法: + +计算元素的“容器率”(Container Ratio):如果一个元素包含了 90% 面积的子元素,它大概率只是个 Wrapper。 + +计算“结构指纹”:
,全页面搜索类似指纹。 + +3. The Mutation Transaction System (变更事务系统) + +职责:确保你的修改是可逆的、原子性的。 + +逻辑: + +用户拖拽 -> 生成 Transaction A (包含 Undo/Redo 数据)。 + +应用到 DOM (Optimistic)。 + +发送给 Server。 + +如果 Server 报错 -> 自动回滚 Transaction A。 + +这能保证无论怎么改,页面不会“挂”掉。 + +总结:你的“护城河”在哪里? + +VisBug 是工具箱,你需要做的是 Figma for Live Code。 + +Cursor 是代码编辑器,你需要做的是 No-Code Interface for Low-Code Output。 + +核心差异化体验公式: + +Canvas 渲染的极致流畅 UI + Figma 级的智能对齐/吸附 + 多模态 AI 的一键重构 + 无缝的本地代码同步 + +如果你能把 "Shadow DOM + Canvas Overlay" 这套架构跑通,你的体验绝对能秒杀目前市面上 99% 的浏览器插件,甚至给用户一种“我在用原生 App”的错觉。这完全是可行的。 + +q: 按照你的说法,使用The Rendering Engine (渲染引擎)的话,那还能实现视频里的拖拽改变布局吗,或者在左边栏能实时修改网页的样式吗 +a: 这是一个非常好的直觉问题。答案是:绝对可以,而且体验会比直接操作 DOM 更好、更丝滑。 + +你可能会担心:如果用 Canvas 画 UI,是不是意味着我跟底下的网页断开连接了? +其实不是。这里采用的是一种**“AR(增强现实)架构”**。 + +简单来说:Canvas 只是你的“眼镜”(负责显示高亮、辅助线、拖拽残影),而你的“手”依然伸到了屏幕里去操作真实的 DOM。 + +我们来看看具体怎么实现这两种场景: + +场景一:拖拽改变布局 (Drag & Drop) + +在“商业级”方案中,拖拽的流程是 Canvas (视觉) 与 DOM (逻辑) 的完美配合: + +鼠标按下 (MouseDown): + +用户在 Canvas 层点击了某个位置。 + +逻辑层:程序通过 document.elementFromPoint(x,y) 瞬间知道用户点到了底下的哪个真实的 DOM 元素(比如一个 div)。 + +视觉层:Canvas 立即在鼠标位置画一个该元素的“截图”(Texture)或者半透明矩形,开始跟随鼠标移动。 + +鼠标拖动 (Dragging) - 性能的关键点: + +视觉层(Canvas):在这一步,完全不操作真实的 DOM。计算插入点、绘制那条“蓝色的插入线”、移动“幽灵元素”,全部在 Canvas 上以 60FPS 绘制。因为不动 DOM,所以绝对不卡,哪怕页面有 1万个节点。 + +逻辑层:后台静默计算:“如果现在松手,它应该插在哪个 DOM 节点的前面”。 + +鼠标松手 (Drop) - 同步时刻: + +逻辑层:JS 执行 parentNode.insertBefore(draggingNode, refNode)。这时候,真实的 DOM 才真正发生移动。 + +视觉层:Canvas 监听到 DOM 变了,瞬间重绘高亮框到新的位置。 + +结论:用户感觉自己在拖拽网页元素,其实他在拖拽 Canvas 上的像素,直到最后一刻才同步给网页。这就是 Figma web 版流畅的秘密。 + +场景二:左边栏实时修改样式 (Style Editing) + +这部分更加简单直接,不需要 Canvas 介入,而是 React 面板与 DOM 的直接通信。 + +左边栏 (React UI): + +你的属性面板(颜色选择器、滑块)是标准的 HTML/React 组件,渲染在 Shadow DOM 里(不在 Canvas 里,方便交互)。 + +修改过程: + +用户拖动 React 面板里的“字号滑块”。 + +逻辑层:JS 监听到变化,直接执行: + +code +JavaScript +download +content_copy +expand_less +targetElement.style.fontSize = newValue + 'px'; + +浏览器行为:浏览器渲染引擎立即重绘该 DOM 节点。用户看到字变大了。 + +Canvas 的响应 (The Sync): + +问题:字变大了,那个 div 的尺寸也撑大了,Canvas 上的高亮蓝框岂不是对不齐了? + +解决:使用 ResizeObserver 监听目标 DOM。一旦 DOM 尺寸变了,通知 Canvas:“喂,那个元素变大了,重画一下蓝框”。 + +结果:高亮框像是有弹性一样,平滑地跟随 DOM 变大。 + +为什么说这比纯 DOM 方案更强? + +如果你不用 Canvas,而是用 div 做高亮框(像 VisBug 那样): + +拖拽时:你需要不断更新那个 div 的 top/left。这会触发浏览器的 Layout Thrashing (布局抖动),在复杂页面上鼠标会跟不上手,感觉“飘”或“卡”。 + +辅助线:如果你想在拖拽时显示“对齐线”或“距离数值”,你需要插入几十个临时的 DOM 节点。这会把页面搞得很脏,甚至破坏原有的 CSS 选择器(比如 :nth-child)。 + +使用 Canvas 引擎方案: + +你可以画任何东西:可以在不触碰 DOM 的情况下,画出 Figma 那样的红色距离标注、智能吸附线、拖拽时的半透明残影。 + +性能隔离:你的 UI 渲染开销完全在 Canvas 层,不影响宿主页面的渲染性能。 + +技术架构总结 +组件 技术选型 职责 +底层 (The Host) 原生 DOM 用户的真实网页,被操作的对象。 +中间层 (Logic) JavaScript 负责事件计算、DOM API 调用、数据打包。 +顶层 (Visual) Canvas (Pixi/Konva) 只负责画框! 画选中框、画标尺、画拖拽残影。它不负责输入框或按钮。 +UI 面板 (Sidebar) React/Vue 侧边栏、工具条。这是标准的 HTML,方便用户输入。 + +所以,你的担心是不存在的。这种架构反而是把“视觉流畅度”和“数据真实性”解耦,是目前前端工程化中最顶级的实现方案。 + +q: 还有个问题,智能语义选择那里,组件边界识别那里,假设我直接先把整个dom发送给大模型,成本会不会有点高,假设是用户选中了才发,感觉有可能会很频繁,实时性可能也不太好,成本其实也高,如果是用一些本地的小模型之类的,那实现成本就变高了,这里有好的方案吗 +a: 你说得非常对。用 LLM 实时分析 DOM 来做“选中”是完全不可行的。 + +原因很简单: + +成本爆炸:DOM 树随便一个页面就是几千个 token,鼠标划过一下算一次请求,钱包扛不住。 + +延迟不可接受:LLM 哪怕再快也要几百毫秒,而“高亮选中”必须是 16ms(1帧)以内的响应,否则手感会极差。 + +杀鸡焉用牛刀:识别“这是一个组件”通常不需要理解语义(比如“这是商品卡片”),只需要识别结构模式(比如“这堆 DOM 结构重复出现了 10 次”)。 + +要在浏览器端实现零成本、实时、精准的组件识别,业界的标准做法是使用**“结构指纹算法” + “启发式规则”**。这是一套纯算法方案,运行在本地 JS 中,耗时在毫秒级。 + +以下是具体的低成本、高性能技术方案: + +方案一:基于“结构指纹”的重复检测 (Structural Fingerprinting) + +原理:组件的核心特征是**“重复”**。一个商品列表里有 20 个卡片,它们的 HTML 结构(标签嵌套顺序、类名)往往是高度相似甚至完全一致的。 + +实现逻辑: + +生成指纹: +当页面加载完,你的插件在后台(Web Worker)遍历一次 DOM 树,给每个节点生成一个简单的 Hash 字符串(指纹)。 + +指纹公式:TagName + DirectChildTags + ClassNameHash + +例如一个卡片的指纹可能是:DIV|IMG,DIV,BUTTON|card-container + +聚类分析: +把所有指纹放到一个 Map 里计数。 + +如果发现指纹 Hash_A 出现了 10 次。 + +推论:这 10 个元素肯定是同一个组件的实例(Instance)。 + +交互应用: + +当用户鼠标划过其中一个卡片时。 + +你的编辑器不仅高亮当前这一个,还可以(用虚线)同时框出其他 9 个。 + +UI 提示:显示“检测到 10 个类似组件”。 + +成本:纯数学计算,0 API 调用,0 延迟。 + +方案二:基于“视觉启发式”的去噪算法 (Visual Heuristics) + +原理:用户眼里的“组件”,通常是一个**“有视觉边界的容器”**。我们可以写一套规则(Heuristics)来模拟人的判断,过滤掉无用的 div。 + +实现逻辑(在 mousemove 时实时计算,开销极低): + +忽略“空气容器”: +获取 ComputedStyle。如果一个元素满足以下所有条件,它大概率只是个布局壳子,应该透传(即不选中它,直接选中它的子元素): + +没有背景色 (backgroundColor 是透明)。 + +没有边框 (borderWidth 为 0)。 + +没有阴影 (boxShadow none)。 + +关键点:它的尺寸(Rect)和它唯一的子元素尺寸几乎一样。 + +捕捉“实体容器”: +反之,如果一个元素满足以下任一条件,它的选中权重大幅增加(鼠标放上去优先吸附它): + +有明显的 border 或 boxShadow。 + +有与父级不同的 backgroundColor。 + +它是 grid 或 flex 的直接子项(Grid Item / Flex Item 通常是组件本体)。 + +它有 cursor: pointer(说明是可交互的)。 + +方案三:基于类名/属性的“正则嗅探” (Naming Sniffing) + +原理:现代前端开发通常有命名规范(BEM, Tailwind, CSS Modules)。 + +实现逻辑: + +关键词加权: +检查 className 或 id 或 data-testid。 +如果包含 card, item, container, wrapper, btn, nav, header 等关键词,选中优先级 +10。 + +框架特征识别: + +React:有时 React DevTools 会留下 \_\_reactFiber... 属性,虽然是内部的,但插件可以读取它来判断组件边界。 + +Vue:寻找 **vue** 属性。 + +Web Components:只要标签名里带横杠(如 ),那 100% 是个组件,直接锁死选中。 + +方案四:按需调用 AI (Lazy AI) + +策略:把 AI 当作**“最后手段”**,而不是“常规手段”。 + +只有在以下情况才发送 DOM 给小模型或大模型: + +用户主动求助:用户选中了一堆乱七八糟的 DOM,点击了“智能分析”按钮。 + +代码生成阶段:用户已经决定要改这个元素了,点击“Apply”时。 + +优化成本的小技巧: + +发送“缩略版” DOM:不要发 innerHTML。只发“骨架”: + +code +Html +download +content_copy +expand_less + + +
+ +
+

{Text}

+

{Text}

+
+
+ +这样 Token 消耗能降低 90%。 + +总结:高性价比技术栈 + +你需要的是 "规则引擎 (Rule Engine)" 而不是 "AI 模型"。 + +模块 技术实现 成本 实时性 +基础选中 elementFromPoint + 视觉去噪规则 (JS) 0 极致 (60fps) +组件识别 DOM 树遍历 + 指纹哈希聚类 (JS) 0 高 (页面加载时算一次) +语义推断 检查类名/属性关键词 (Regex) 0 极致 +复杂重构 发送精简版 DOM 给 LLM (API) 低 中 (需等待) + +结论: +你可以完全通过本地算法实现视频里那种“智能选中”的效果。 +比如,当鼠标悬停在一个 div 上时,算法判断它没有背景且只有 1 个子元素,自动帮你选中那个子元素;或者算法发现这个 div 的结构在页面上出现了 20 次,自动标注它为“List Item”。 + +这不仅省钱,体验也比等待 AI 响应要好得多。商业级工具(如 Builder.io, Webflow)都是这么做的。 diff --git a/editor.png b/editor.png new file mode 100644 index 0000000..919615c Binary files /dev/null and b/editor.png differ diff --git a/quick-panel-ui.html b/quick-panel-ui.html new file mode 100644 index 0000000..f373014 --- /dev/null +++ b/quick-panel-ui.html @@ -0,0 +1,1694 @@ + + + + + + Chrome Quick Launcher - UI Design + + + + + +
+

Chrome Quick Launcher - UI 设计稿

+

6个设计版本 · 横向滚动查看 · 每版本含多种状态

+
+ +
+ +
+
+ Version 1 +

极简白色版

+

Clean & Minimal

+
+ + +
+ 默认状态 +
+
+
+ + + ESC +
+
+
+
快捷操作
+
+
+
+ +
+
+
搜索标签页
+
在已打开的标签页中搜索
+
+ ⌘T +
+
+
+ +
+
+
搜索书签
+
快速查找书签
+
+ ⌘B +
+
+
+ +
+
+
浏览历史
+
搜索历史记录
+
+ ⌘H +
+
+
+ +
+
+
运行命令
+
执行快捷命令
+
+ ⌘K +
+
+
+
+
+ ↑↓ 导航 + ↵ 选择 +
+
+
+ 就绪 +
+
+
+
+ + +
+ 搜索结果 +
+
+
+ + + 8 结果 +
+
+
+
标签页
+
+
+ +
+
GitHub - Where the world builds software
+
github.com
+
+ 切换 +
+
+ +
+
vuejs/vue: A progressive framework
+
github.com/vuejs/vue
+
+
+
+
书签
+
+
+
+ +
+
+
GitHub Awesome Lists
+
+
+
+
搜索引擎
+
+
+
+ +
+
+
在 Google 搜索 "github"
+
+ +
+
+
+
+
+ ↵ 打开 + ⌘↵ 新标签页 +
+
+
+
+ + +
+ 命令面板 +
+
+
+ 命令 + + +
+
+
+
页面操作
+
+
+
+ +
+ 截取屏幕 + +
+
+
+ +
+ 复制页面链接 + +
+
+
+ +
+ 生成二维码 +
+
+
+ +
+ 翻译页面 +
+
+
+
+
⌫ 返回
+
+
+
+
+ + +
+
+ Version 2 +

深色版

+

Dark Theme

+
+ + +
+ 默认状态 +
+
+
+ + + ESC +
+
+
+
快捷操作
+
+
+
+ +
+
+
搜索标签页
+
在已打开的标签页中搜索
+
+ ⌘T +
+
+
+ +
+
+
搜索书签
+
快速查找书签
+
+ ⌘B +
+
+
+ +
+
+
浏览历史
+
搜索历史记录
+
+ ⌘H +
+
+
+ +
+
+
运行命令
+
执行快捷命令
+
+ ⌘K +
+
+
+
+
+ ↑↓ 导航 + ↵ 选择 +
+
+
+ 就绪 +
+
+
+
+ + +
+ 标签页搜索 +
+
+
+ + + 5 个标签页 +
+
+
+
+
+ +
+
React – A JavaScript library
+
reactjs.org
+
+
+ 切换 + +
+
+
+ +
+
facebook/react: A declarative UI library
+
github.com/facebook/react
+
+ +
+
+
R
+
+
React Documentation
+
react.dev
+
+ +
+
+
+
+
+ ↵ 切换 · ⌘X 关闭 +
+
+
+
+ + +
+ 计算器 +
+
+
+ + +
+
+
+
+
计算结果
+
2.0736
+
≈ 2.07 百万像素
+
+
+ + +
+
+
+
支持 +、-、*、/、()、^、sqrt()、sin()、cos()
+
+
+
+
+ + +
+
+ Version 3 +

毛玻璃版

+

Glassmorphism

+
+ + +
+ 默认状态 +
+
+
+
+
+
+ +
+ + ⌘K +
+
+
+
+
+
+ +
+ 标签页 +
+
+
+ +
+ 书签 +
+
+
+ +
+ 历史 +
+
+
+ +
+ 命令 +
+
+
+
最近使用
+
+
+
+ +
+ 截取屏幕 +
+
+
+ +
+ 复制页面链接 +
+
+
+
+
+ 输入关键词开始搜索 + +
+
+
+
+ + +
+ 搜索建议 +
+
+
+
+
+
+ +
+ + 6 个结果 +
+
+
+
+ 标签页 +
+
+
+ +
+
Vue.js - Progressive Framework
+
vuejs.org
+
+ 切换 +
+
+ +
+
vuejs/vue - GitHub
+
github.com
+
+
+
+
+ 搜索引擎 +
+
+
+
+ +
+ 在 Google 搜索 "vue" + +
+
+
+
+
+ ↑↓ 导航 · + ↵ 打开 +
+
+
+
+
+ + +
+ 扩展管理 +
+
+
+
+
+
+ +
+ + 6 个扩展 +
+
+
+
已启用
+
+
+
+ +
+
+
uBlock Origin
+
广告拦截器
+
+
+
+
+
+
+
+ +
+
+
React Developer Tools
+
React 调试工具
+
+
+
+
+
+
+
已禁用
+
+
+
+ +
+
+
Google 翻译
+
网页翻译
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+ Version 4 +

卡片式版

+

Card Based

+
+ + +
+ 默认状态 +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+ 快捷入口 + 今日 12 次 +
+
+
+
+ +
+
+
标签页
+
12 个
+
+
+
+
+ +
+
+
书签
+
156 个
+
+
+
+
+ +
+
+
历史
+
最近 7 天
+
+
+
+
+ +
+
+
下载
+
3 进行中
+
+
+
+
+
+
+ 常用命令 + +
+
+ + 截图 + + + 复制链接 + + + 二维码 + + + 翻译 + +
+
+
+
+ ⌘K 打开 + +
+
+
+ + +
+ 标签页管理 +
+
+
+ +
+ + + 3 匹配 +
+
+
+
+
+
+
+
+ 设计资源 +
+ 3 个标签 +
+
+
+ + Figma – 设计文件 + 活跃 +
+
+
D
+ Dribbble - 灵感 +
+
+
+
+
+
U
+
+
UI 设计趋势 2024
+
medium.com
+
+
+
+
+
+
+ + +
+ 已选 0 个 +
+
+
+ + +
+ 番茄钟 +
+
+
+
+ +
+
+
番茄钟
+
专注工作,提高效率
+
+
+
+
+
+
23:45
+
专注时间剩余
+
+
+ + + +
+
+
+
+ 今日记录 + 共 4 个番茄 +
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ Version 5 +

紧凑版

+

Compact Mode

+
+ + +
+ 默认状态 +
+
+ + + 全部 +
+
+
快捷操作
+
+ + 搜索标签页 + ⌘T +
+
+ + 搜索书签 + ⌘B +
+
+ + 浏览历史 + ⌘H +
+
+ + 运行命令 + ⌘K +
+
+ + 截取屏幕 + ⌘S +
+
+ + 复制链接 + ⌘L +
+
+
+ ↑↓ 导航 · ↵ 选择 · esc 关闭 +
+
+
+ + +
+ 搜索结果 +
+
+ + + 5 结果 +
+
+
+ 标签页 +
+
+ + Tailwind CSS - Modern websites + 切换 +
+
+ + tailwindlabs/tailwindcss - GitHub +
+
+ 书签 +
+
+ + Tailwind UI - Components +
+
+ 搜索引擎 +
+
+ + 在 Google 搜索 + +
+
+
+ ↵ 打开 · ⌘↵ 新标签页 +
+
+
+ + +
+ 快速复制 +
+
+ > + 复制链接 + 选择格式 +
+
+
+ +
+ 纯 URL +
https://github.com/microsoft/vscode
+
+ ↵ +
+
+ +
+ Markdown +
[VS Code](https://github.com/...)
+
+
+
+ +
+ HTML +
<a href="...">VS Code</a>
+
+
+
+ +
+ 标题 + URL +
VS Code - https://github.com/...
+
+
+
+
+ ⌫ 返回 + 复制后自动关闭 +
+
+
+
+ + +
+
+ Version 6 +

液态玻璃版

+

Liquid Glass (iOS 26 Style)

+
+ + +
+ 默认状态 +
+
+
+
+
+
+ +
+ +
⌘K
+
+
+
+
+
+
+ +
+ 标签页 +
+
+
+ +
+ 书签 +
+
+
+ +
+ 历史 +
+
+
+ +
+ 命令 +
+
+
+
最近使用
+
+
+
+ +
+ 截取屏幕 + +
+
+
+ +
+ 复制链接 + +
+
+
+
+
+
+ ↑↓ 导航 + ↵ 选择 + esc 关闭 +
+
+
+
+
+ + +
+ 搜索模式 +
+
+
+
+
+
+ +
+ + 4 结果 +
+
+
+
+
+ 标签页 +
+
+
+ +
+
Notion – Your workspace
+
notion.so
+
+
切换
+
+
+ +
+
notion-enhancer - GitHub
+
github.com
+
+
+
+
+
+ 网页搜索 +
+
+
+
+ +
+ 在 Google 搜索 "notion" + +
+
+
+
+
+ ↵ 打开 + ⌘↵ 新标签 +
+
+
+
+
+ + +
+ 深色模式 +
+
+
+
+
+
+ +
+ +
⌘K
+
+
+
+
+
+
+ +
+ 标签页 +
+
+
+ +
+ 书签 +
+
+
+ +
+ 历史 +
+
+
+ +
+ 命令 +
+
+
+
最近使用
+
+
+
+ +
+ 截取屏幕 + +
+
+
+ +
+ 复制链接 + +
+
+
+
+
+
+ ↑↓ 导航 + ↵ 选择 + esc 关闭 +
+
+
+
+
+
+
+ +