mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-24 23:24:04 +08:00
feat: [WIP]优化web-builder
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
-179
@@ -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.
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 476 KiB |
+1694
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user