feat: [WIP]优化web-builder

This commit is contained in:
hangerye
2025-12-18 20:23:57 +08:00
parent 4649638f4e
commit 668aaec212
22 changed files with 9204 additions and 383 deletions
@@ -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<string, string>;
/** 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<string, string>;
}
/**
* 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;
}
}
@@ -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<void> {
}
}
/**
* 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<void> {
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 {};
}
}
@@ -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`);
});
@@ -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;
@@ -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,
};
}
@@ -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(),
};
}
@@ -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}`;
}
@@ -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);
};
}
@@ -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<string, string>;
}
/** 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<string, string>;
after: Record<string, string>;
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<string, unknown> | null {
if (value && typeof value === 'object') {
return value as Record<string, unknown>;
}
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<string, unknown>;
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<string, unknown>;
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<string>();
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<string, string>;
after: Record<string, string>;
set: Record<string, string>;
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<string, string> = {};
const after: Record<string, string> = {};
const set: Record<string, string> = {};
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<unknown> {
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<unknown> {
const payload = buildApplyPayload(tx, options);
if (!payload) {
throw new Error('Unable to build payload from transaction');
}
return sendApplyPayload(payload);
}
@@ -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(),
};
}
@@ -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<string, string> | 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<string>();
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,
};
}
@@ -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<DOMRectReadOnly, 'left' | 'top' | 'width' | 'height'>;
/** 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<string, BoxStyle>;
// =============================================================================
// 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<HTMLCanvasElement>(
`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(),
};
}
@@ -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<string, unknown>).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<Element, CSSStyleDeclaration>,
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<Element, CandidateMeta>();
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<Element, CSSStyleDeclaration>();
const scored: Array<SelectionCandidate & CandidateMeta> = [];
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<Element, CSSStyleDeclaration>();
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(),
};
}
@@ -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 = `
<span>Web Editor</span>
<span class="we-badge">V2</span>
`;
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 = `
<span class="we-status-dot"></span>
<span>Editor active - Hover to select elements</span>
`;
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();
},
};
}
@@ -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<void | ApplyResult>;
/** 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<unknown> {
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<void> {
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(),
};
}
@@ -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<K extends keyof WindowEventMap>(
target: Window,
type: K,
listener: (ev: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
listen<K extends keyof DocumentEventMap>(
target: Document,
type: K,
listener: (ev: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
listen<K extends keyof HTMLElementEventMap>(
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;
}
}
+12 -12
View File
@@ -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',
+1203
View File
File diff suppressed because it is too large Load Diff
-179
View File
@@ -1,179 +0,0 @@
# Web Visual Editor (Chrome Extension) — Implementation Plan
## 0) Goal
Build a **visual in-page editor** for local dev pages (e.g. `localhost`) that:
- Enters/exits **Edit Mode** via **keyboard shortcut**, **right-click context menu**, or a **Popup button**.
- Lets the user **hover + click to select** an element with a high-performance overlay.
- Allows **quick visual edits** (v1: text + inline styles) with optimistic DOM updates.
- Sends a structured “intent payload” to the existing **AgentChat** backend so the AI Agent can **persist changes into source code**.
Non-goals (v1):
- Full Figma/Webflow-grade layout editing, multi-select, constraints, snapping, history/undo.
- Cross-origin iframe editing (same-origin can be added later).
- Perfect framework source mapping in production builds (dev-mode metadata first).
## 1) Context Scan (Evidence-Based)
### 1.1 Similar patterns we will reuse
1. **Record/Replay triggers** (hotkeys + context menus)
- `app/chrome-extension/wxt.config.ts` (manifest `commands`)
- `app/chrome-extension/entrypoints/background/record-replay/index.ts` (`chrome.commands` + `chrome.contextMenus`)
2. **Injected overlay + selection**
- `app/chrome-extension/inject-scripts/accessibility-tree-helper.js` (`rr_picker_start` overlay + capture listeners)
3. **Background-driven injection + context menu**
- `app/chrome-extension/entrypoints/background/element-marker/index.ts` (idempotent inject + overlay control)
### 1.2 Integration points (data-path)
- **Popup (Vue)** → `chrome.runtime.sendMessage` → **Background**
- **Background** → `chrome.scripting.executeScript` → **Injected Script** (ISOLATED world)
- **Injected Script** → `chrome.runtime.sendMessage` → **Background**
- **Background** → HTTP `POST /agent/chat/:sessionId/act` → **Native Server** → **AgentChatService** → **Engine** (Codex/Claude/…)
### 1.3 Tech stack + conventions
- Extension: Vue 3 + WXT + TS, injected scripts are plain JS under `app/chrome-extension/inject-scripts/`.
- Backend: Fastify (native server), AgentChat already exists.
- Formatting: Prettier + ESLint.
- Tests: Jest exists in `app/native-server` (extension has no automated tests today).
## 2) Key Questions (Prioritized)
High:
- What is the **minimum set of editing operations** to ship v1 end-to-end (visual edit → AI persists)?
- What is the **source localization contract** we can reliably provide (React/Vue dev metadata vs fallback fingerprint)?
- How do we ensure **Edit Mode does not trigger page actions** (navigation/form submit) while keeping scrolling usable?
Medium:
- Should we open the Side Panel (AgentChat) automatically after “Sync to Code”?
- How should we store per-tab editor state (ephemeral per page load vs persisted)?
Low:
- Cross-frame (same-origin iframe) support.
- Advanced overlay rendering (guides, spacing, multi-rect).
## 3) Target Contract (v1)
### 3.1 Background messages
- `BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE`
- Called from Popup / commands / context menu
- Effect: inject editor script if needed, then toggle edit mode on the active tab
- `BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY`
- Called from injected script when the user clicks “Sync to Code”
- Effect: build an AgentChat prompt and call `/agent/chat/:sessionId/act`
### 3.2 Injected script messages (tab → injected)
- `action: "web_editor_ping"` → `{ status: "pong" }`
- `action: "web_editor_toggle"` → `{ active: boolean }`
- (optional) `action: "web_editor_start" | "web_editor_stop"`
### 3.3 Apply payload schema (injected → background)
```ts
type WebEditorInstructionType = 'update_text' | 'update_style';
interface WebEditorFingerprint {
tag: string;
id?: string;
classes: string[];
text?: string; // short snippet
}
interface WebEditorApplyPayload {
pageUrl: string;
targetFile?: string; // best-effort (React/Vue dev metadata)
fingerprint: WebEditorFingerprint;
techStackHint?: string[];
instruction: {
type: WebEditorInstructionType;
description: string;
text?: string;
style?: Record<string, string>;
};
}
```
### 3.4 Prompt template (background → AgentChat)
The prompt MUST be deterministic, explicit, and tool-friendly:
- If `targetFile` is available: instruct to edit that file (and related CSS modules if needed).
- Else: instruct to `rg` search using `fingerprint.text` and/or stable classes.
- Prefer Tailwind class edits if Tailwind is detected; otherwise update CSS module / inline styles.
- Apply the requested change and keep other behavior unchanged.
## 4) Milestones & Tasks (Checklist)
### M1 — Edit Mode activation (toggle)
- [x] Add manifest command: `toggle_web_editor` (suggested key: `Ctrl+Shift+E`)
- [x] Add background module `web-editor` with:
- [x] context menu item “Toggle Web Editor”
- [x] `chrome.commands` listener for `toggle_web_editor`
- [x] runtime message handler for Popup toggle
- [x] idempotent injection + `web_editor_toggle` tab message
- [x] Add Popup section with a “Toggle Web Editor” button
### M2 — In-page overlay + selection (visual layer)
- [x] Add `inject-scripts/web-editor.js`:
- [x] High-perf overlay using a fixed `<canvas>` (DPR-aware) + RAF loop
- [x] Hover highlight + click-to-select
- [x] Smart selection heuristics (skip transparent wrappers when appropriate)
- [x] Capture-phase event interception (prevent page actions while editing)
- [x] Exit via `Esc`
### M3 — Basic visual editing (v1)
- [x] Floating toolbar for selected element
- [x] Text edit (optimistic `textContent` update)
- [x] Inline style edit (CSS declarations → `style.setProperty`)
### M4 — Source localization (best-effort)
- [x] React dev metadata detection (Fiber `_debugSource.fileName`)
- [x] Vue 3 dev metadata detection (`instance.type.__file`)
- [x] `node_modules` escape: ignore and climb DOM parents
- [x] Fallback fingerprint capture (tag/id/classes/text snippet)
### M5 — Agent bridge (persist to code)
- [x] Read Agent project selection from `chrome.storage.local`:
- `agent-selected-project-id`
- `agent-project-root-override`
- [x] Background calls `POST /agent/chat/:sessionId/act` with `{ instruction, projectId, projectRoot }`
- [x] Injected UI shows success/error toast with returned `requestId`
### M6 — Verification
- [ ] `pnpm -r lint` (or scoped package lint) (blocked by pre-existing errors)
- [ ] `pnpm --filter mcp-chrome-bridge test` (native server regression) (currently failing in repo)
- [ ] Manual smoke:
- Toggle Edit Mode via popup / context menu / hotkey
- Select element, edit text/style, “Sync to Code”
- Confirm AgentChat receives request (sidepanel) and code changes trigger HMR
## 5) Risks & Mitigations (v1)
- **React/Vue metadata missing in prod** → fallback fingerprint + `rg` search prompt.
- **Overlay intercepts too much** → allow scroll/wheel; allow toolbar interactions.
- **CORS / server unavailable** → do HTTP from background + show actionable error.
## 6) Progress Log
2025-12-17: Implemented M1–M5 end-to-end. Repo-wide lint/typecheck/tests are currently failing due to unrelated pre-existing issues; manual smoke test is the recommended verification step for this feature.
+1035 -180
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

+1694
View File
File diff suppressed because it is too large Load Diff