mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
chore: web-editor优化
This commit is contained in:
@@ -30,7 +30,8 @@
|
||||
"Bash(pnpm compile:*)",
|
||||
"Bash(pnpm run compile)",
|
||||
"Bash(pnpm eslint:*)",
|
||||
"Bash(pnpm add:*)"
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(pnpm test:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -79,6 +79,7 @@ export const FILE_TYPES = {
|
||||
export const NETWORK_FILTERS = {
|
||||
// Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'
|
||||
EXCLUDED_DOMAINS: [
|
||||
// Google
|
||||
'google-analytics.com',
|
||||
'googletagmanager.com',
|
||||
'analytics.google.com',
|
||||
@@ -88,20 +89,37 @@ export const NETWORK_FILTERS = {
|
||||
'stats.g.doubleclick.net',
|
||||
'adservice.google.com',
|
||||
'pagead2.googlesyndication.com',
|
||||
// Amazon
|
||||
'amazon-adsystem.com',
|
||||
// Microsoft
|
||||
'bat.bing.com',
|
||||
'clarity.ms',
|
||||
// Facebook
|
||||
'connect.facebook.net',
|
||||
'facebook.com/tr',
|
||||
// Twitter
|
||||
'analytics.twitter.com',
|
||||
'static.hotjar.com',
|
||||
'script.hotjar.com',
|
||||
'ads-twitter.com',
|
||||
// Other ad networks
|
||||
'ads.yahoo.com',
|
||||
'adroll.com',
|
||||
'adnxs.com',
|
||||
'criteo.com',
|
||||
'quantserve.com',
|
||||
'scorecardresearch.com',
|
||||
// Analytics & session recording
|
||||
'segment.io',
|
||||
'amplitude.com',
|
||||
'mixpanel.com',
|
||||
'optimizely.com',
|
||||
'scorecardresearch.com',
|
||||
'quantserve.com',
|
||||
'static.hotjar.com',
|
||||
'script.hotjar.com',
|
||||
'crazyegg.com',
|
||||
'clicktale.net',
|
||||
'mouseflow.com',
|
||||
'fullstory.com',
|
||||
// LinkedIn (tracking pixels)
|
||||
'linkedin.com/px',
|
||||
],
|
||||
// Static resource extensions (used when includeStatic=false)
|
||||
STATIC_RESOURCE_EXTENSIONS: [
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ActionPolicy,
|
||||
ExecutableAction,
|
||||
ExecutableActionType,
|
||||
ExecutionFlags,
|
||||
VariableStore,
|
||||
} from './types';
|
||||
|
||||
@@ -87,6 +88,8 @@ export function execCtxToActionCtx(
|
||||
stepId?: string;
|
||||
runId?: string;
|
||||
pushLog?: (entry: unknown) => void;
|
||||
/** Execution flags to pass to action handlers */
|
||||
execution?: ExecutionFlags;
|
||||
},
|
||||
): ActionExecutionContext {
|
||||
// Use provided stepId for proper log attribution, fallback to 'action' only if not provided
|
||||
@@ -104,6 +107,7 @@ export function execCtxToActionCtx(
|
||||
});
|
||||
},
|
||||
pushLog: options?.pushLog,
|
||||
execution: options?.execution,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -376,6 +380,31 @@ export type StepExecutionAttempt =
|
||||
| { supported: true; result: ExecResult }
|
||||
| { supported: false; reason: string };
|
||||
|
||||
/**
|
||||
* Options for step executor
|
||||
*/
|
||||
export interface StepExecutorOptions {
|
||||
runId?: string;
|
||||
pushLog?: (entry: unknown) => void;
|
||||
/**
|
||||
* If true, throws on unsupported step types instead of returning { supported: false }
|
||||
* Use this in strict mode where all steps must go through ActionRegistry
|
||||
*/
|
||||
strict?: boolean;
|
||||
/**
|
||||
* Skip ActionRegistry retry policy.
|
||||
* When true, the action's retry policy is removed before execution.
|
||||
* Use this when StepRunner already handles retry via withRetry().
|
||||
*/
|
||||
skipRetry?: boolean;
|
||||
/**
|
||||
* Skip navigation waiting inside action handlers.
|
||||
* When true, handlers like click/navigate skip their internal nav-wait logic.
|
||||
* Use this when StepRunner already handles navigation waiting.
|
||||
*/
|
||||
skipNavWait?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a step executor that uses ActionRegistry
|
||||
*
|
||||
@@ -391,18 +420,10 @@ export function createStepExecutor(registry: ActionRegistry) {
|
||||
ctx: ExecCtx,
|
||||
step: Step,
|
||||
tabId: number,
|
||||
options?: {
|
||||
runId?: string;
|
||||
pushLog?: (entry: unknown) => void;
|
||||
/**
|
||||
* If true, throws on unsupported step types instead of returning { supported: false }
|
||||
* Use this in strict mode where all steps must go through ActionRegistry
|
||||
*/
|
||||
strict?: boolean;
|
||||
},
|
||||
options?: StepExecutorOptions,
|
||||
): Promise<StepExecutionAttempt> {
|
||||
// Convert step to action
|
||||
const action = stepToAction(step);
|
||||
let action = stepToAction(step);
|
||||
|
||||
if (!action) {
|
||||
const reason = `Unsupported step type for ActionRegistry: ${step.type}`;
|
||||
@@ -412,6 +433,12 @@ export function createStepExecutor(registry: ActionRegistry) {
|
||||
return { supported: false, reason };
|
||||
}
|
||||
|
||||
// Skip retry policy if StepRunner handles it
|
||||
// This avoids double retry: StepRunner.withRetry() + ActionRegistry.retry
|
||||
if (options?.skipRetry === true && action.policy?.retry) {
|
||||
action = { ...action, policy: { ...action.policy, retry: undefined } };
|
||||
}
|
||||
|
||||
// Check if handler exists
|
||||
const handler = registry.get(action.type);
|
||||
if (!handler) {
|
||||
@@ -422,11 +449,16 @@ export function createStepExecutor(registry: ActionRegistry) {
|
||||
return { supported: false, reason };
|
||||
}
|
||||
|
||||
// Build execution flags for handlers
|
||||
const execution: ExecutionFlags | undefined =
|
||||
options?.skipNavWait === true ? { skipNavWait: true } : undefined;
|
||||
|
||||
// Convert context with proper stepId for log attribution
|
||||
const actionCtx = execCtxToActionCtx(ctx, tabId, {
|
||||
stepId: step.id,
|
||||
runId: options?.runId,
|
||||
pushLog: options?.pushLog,
|
||||
execution,
|
||||
});
|
||||
|
||||
// Execute via registry (includes retry, timeout, hooks)
|
||||
|
||||
+10
-2
@@ -41,6 +41,8 @@ async function executeClick<T extends 'click' | 'dblclick'>(
|
||||
): Promise<ActionExecutionResult<T>> {
|
||||
const vars = ctx.vars;
|
||||
const tabId = ctx.tabId;
|
||||
// Check if StepRunner owns nav-wait (skip internal nav-wait logic)
|
||||
const skipNavWait = ctx.execution?.skipNavWait === true;
|
||||
|
||||
if (typeof tabId !== 'number') {
|
||||
return failed('TAB_NOT_FOUND', 'No active tab found');
|
||||
@@ -49,7 +51,8 @@ async function executeClick<T extends 'click' | 'dblclick'>(
|
||||
// Ensure page is read before locating element
|
||||
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
|
||||
|
||||
const beforeUrl = await readTabUrl(tabId);
|
||||
// Only read beforeUrl if we need to do nav-wait
|
||||
const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);
|
||||
const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
|
||||
action.params.target,
|
||||
vars,
|
||||
@@ -108,7 +111,12 @@ async function executeClick<T extends 'click' | 'dblclick'>(
|
||||
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
|
||||
}
|
||||
|
||||
// Post-click wait handling
|
||||
// Skip post-click wait if StepRunner handles it
|
||||
if (skipNavWait) {
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
// Post-click wait handling (only when handler owns nav-wait)
|
||||
const waitMs = clampInt(
|
||||
action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
|
||||
0,
|
||||
|
||||
+21
-10
@@ -33,17 +33,22 @@ export const navigateHandler: ActionHandler<'navigate'> = {
|
||||
run: async (ctx, action) => {
|
||||
const vars = ctx.vars;
|
||||
const tabId = ctx.tabId;
|
||||
// Check if StepRunner owns nav-wait (skip internal nav-wait logic)
|
||||
const skipNavWait = ctx.execution?.skipNavWait === true;
|
||||
|
||||
if (typeof tabId !== 'number') {
|
||||
return failed('TAB_NOT_FOUND', 'No active tab found');
|
||||
}
|
||||
|
||||
const beforeUrl = await readTabUrl(tabId);
|
||||
const waitMs = clampInt(
|
||||
action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
|
||||
0,
|
||||
ENGINE_CONSTANTS.MAX_WAIT_MS,
|
||||
);
|
||||
// Only read beforeUrl and calculate waitMs if we need to do nav-wait
|
||||
const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);
|
||||
const waitMs = skipNavWait
|
||||
? 0
|
||||
: clampInt(
|
||||
action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
|
||||
0,
|
||||
ENGINE_CONSTANTS.MAX_WAIT_MS,
|
||||
);
|
||||
|
||||
// Handle page refresh
|
||||
if (action.params.refresh) {
|
||||
@@ -58,8 +63,11 @@ export const navigateHandler: ActionHandler<'navigate'> = {
|
||||
return failed('NAVIGATION_FAILED', errorMsg);
|
||||
}
|
||||
|
||||
await waitForNavigationDone(beforeUrl, waitMs);
|
||||
await ensureReadPageIfWeb();
|
||||
// Skip nav-wait if StepRunner handles it
|
||||
if (!skipNavWait) {
|
||||
await waitForNavigationDone(beforeUrl, waitMs);
|
||||
await ensureReadPageIfWeb();
|
||||
}
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
@@ -85,8 +93,11 @@ export const navigateHandler: ActionHandler<'navigate'> = {
|
||||
return failed('NAVIGATION_FAILED', errorMsg);
|
||||
}
|
||||
|
||||
await waitForNavigationDone(beforeUrl, waitMs);
|
||||
await ensureReadPageIfWeb();
|
||||
// Skip nav-wait if StepRunner handles it
|
||||
if (!skipNavWait) {
|
||||
await waitForNavigationDone(beforeUrl, waitMs);
|
||||
await ensureReadPageIfWeb();
|
||||
}
|
||||
|
||||
return { status: 'success' };
|
||||
},
|
||||
|
||||
@@ -751,6 +751,18 @@ export type ActionOutput<T extends ActionType> = T extends keyof ActionOutputsBy
|
||||
|
||||
export type ValidationResult = { ok: true } | { ok: false; errors: NonEmptyArray<string> };
|
||||
|
||||
/**
|
||||
* Execution flags for coordinating with orchestrator policies.
|
||||
* Used to avoid duplicate retry/nav-wait when StepRunner owns these policies.
|
||||
*/
|
||||
export interface ExecutionFlags {
|
||||
/**
|
||||
* When true, navigation waiting should be handled by StepRunner.
|
||||
* Action handlers (click, navigate) should skip their internal nav-wait logic.
|
||||
*/
|
||||
skipNavWait?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionExecutionContext {
|
||||
vars: VariableStore;
|
||||
tabId: number;
|
||||
@@ -765,6 +777,11 @@ export interface ActionExecutionContext {
|
||||
* Action handlers may emit richer entries (e.g. selector fallback) via this hook.
|
||||
*/
|
||||
pushLog?: (entry: unknown) => void;
|
||||
/**
|
||||
* Execution flags provided by the orchestrator.
|
||||
* Handlers should respect these flags to avoid duplicating StepRunner policies.
|
||||
*/
|
||||
execution?: ExecutionFlags;
|
||||
}
|
||||
|
||||
export type ControlDirective =
|
||||
|
||||
+53
-10
@@ -35,7 +35,10 @@ export interface ExecutionModeConfig {
|
||||
|
||||
/**
|
||||
* Step types that should use actions execution (allowlist)
|
||||
* Only applies in hybrid mode. If empty, all supported types use actions.
|
||||
* Only applies in hybrid mode.
|
||||
* - If undefined: uses MINIMAL_HYBRID_ACTION_TYPES (safest default)
|
||||
* - If empty Set (size=0): falls back to MIGRATED_ACTION_TYPES policy
|
||||
* - If non-empty Set: only these types use actions
|
||||
*/
|
||||
actionsAllowlist?: Set<string>;
|
||||
|
||||
@@ -46,8 +49,11 @@ export interface ExecutionModeConfig {
|
||||
logFallbacks?: boolean;
|
||||
|
||||
/**
|
||||
* Skip ActionRegistry's built-in retry/timeout when StepRunner handles them
|
||||
* @default true - StepRunner already handles retry/timeout via withRetry and deadline
|
||||
* Skip ActionRegistry's built-in retry policy.
|
||||
* When true, action.policy.retry is removed before execution.
|
||||
* @default true - StepRunner already handles retry via withRetry()
|
||||
*
|
||||
* Note: ActionRegistry timeout is NOT disabled (provides per-action timeout safety).
|
||||
*/
|
||||
skipActionsRetry?: boolean;
|
||||
|
||||
@@ -69,6 +75,29 @@ export const DEFAULT_EXECUTION_MODE_CONFIG: ExecutionModeConfig = {
|
||||
skipActionsNavWait: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal allowlist for initial hybrid rollout.
|
||||
*
|
||||
* This keeps high-risk step types (navigation/click/tab management) on legacy
|
||||
* until policy (retry/timeout/nav-wait) and tab cursor semantics are unified.
|
||||
*
|
||||
* These types are chosen for their low risk:
|
||||
* - No navigation side effects
|
||||
* - No tab management
|
||||
* - No complex timing requirements
|
||||
* - Simple input/output semantics
|
||||
*/
|
||||
export const MINIMAL_HYBRID_ACTION_TYPES = new Set<string>([
|
||||
'fill', // Form input - no navigation
|
||||
'key', // Keyboard input - no navigation
|
||||
'scroll', // Viewport manipulation - no navigation
|
||||
'drag', // Drag and drop - local operation
|
||||
'wait', // Condition waiting - no side effects
|
||||
'delay', // Simple delay - no side effects
|
||||
'screenshot', // Capture only - no side effects
|
||||
'assert', // Validation only - no side effects
|
||||
]);
|
||||
|
||||
/**
|
||||
* Step types that are fully migrated and tested with ActionRegistry
|
||||
* These are safe to run in actions mode
|
||||
@@ -165,21 +194,34 @@ export function shouldUseActions(step: Step, config: ExecutionModeConfig): boole
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hybrid execution mode config for gradual migration
|
||||
* Starts with only the most stable types enabled for actions
|
||||
* Create a hybrid execution mode config for gradual migration.
|
||||
*
|
||||
* By default uses MINIMAL_HYBRID_ACTION_TYPES as allowlist, which excludes
|
||||
* high-risk types (navigate/click/tab management) from actions execution.
|
||||
*
|
||||
* @param overrides - Optional overrides for the config
|
||||
* @param overrides.actionsAllowlist - Set of step types to execute via actions.
|
||||
* If provided with size > 0, only these types use actions.
|
||||
* If empty Set, falls back to MIGRATED_ACTION_TYPES.
|
||||
* If undefined, uses MINIMAL_HYBRID_ACTION_TYPES (safest default).
|
||||
*/
|
||||
export function createHybridConfig(overrides?: Partial<ExecutionModeConfig>): ExecutionModeConfig {
|
||||
return {
|
||||
...DEFAULT_EXECUTION_MODE_CONFIG,
|
||||
mode: 'hybrid',
|
||||
legacyOnlyTypes: LEGACY_ONLY_TYPES,
|
||||
legacyOnlyTypes: new Set(LEGACY_ONLY_TYPES),
|
||||
actionsAllowlist: new Set(MINIMAL_HYBRID_ACTION_TYPES),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strict actions mode config for testing
|
||||
* All steps must be handled by ActionRegistry or throw
|
||||
* Create a strict actions mode config for testing.
|
||||
* All steps must be handled by ActionRegistry or throw.
|
||||
*
|
||||
* Note: Even in actions mode, StepRunner remains the policy authority for
|
||||
* retry/nav-wait. This ensures consistent behavior across all execution modes
|
||||
* and avoids double-strategy issues.
|
||||
*/
|
||||
export function createActionsOnlyConfig(
|
||||
overrides?: Partial<ExecutionModeConfig>,
|
||||
@@ -187,8 +229,9 @@ export function createActionsOnlyConfig(
|
||||
return {
|
||||
...DEFAULT_EXECUTION_MODE_CONFIG,
|
||||
mode: 'actions',
|
||||
skipActionsRetry: false,
|
||||
skipActionsNavWait: false,
|
||||
// Keep StepRunner as policy authority - skip ActionRegistry's internal policies
|
||||
skipActionsRetry: true,
|
||||
skipActionsNavWait: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
+36
-6
@@ -68,9 +68,20 @@ export interface StepExecutorInterface {
|
||||
|
||||
/**
|
||||
* Legacy step executor using nodes/executeStep
|
||||
*
|
||||
* This executor delegates to the existing node execution system.
|
||||
* The options parameter is accepted but not used - retry/timeout/navigation
|
||||
* waiting are handled by StepRunner to maintain existing behavior.
|
||||
*/
|
||||
export class LegacyStepExecutor implements StepExecutorInterface {
|
||||
async execute(ctx: ExecCtx, step: Step): Promise<StepExecutionResult> {
|
||||
async execute(
|
||||
ctx: ExecCtx,
|
||||
step: Step,
|
||||
_options: StepExecutionOptions,
|
||||
): Promise<StepExecutionResult> {
|
||||
// Note: tabId from options is not used here because legacy executeStep
|
||||
// queries the active tab internally. In hybrid/actions mode, tabId is
|
||||
// passed through to ActionRegistry handlers.
|
||||
const result = await legacyExecuteStep(ctx, step);
|
||||
return {
|
||||
result: result || {},
|
||||
@@ -78,7 +89,7 @@ export class LegacyStepExecutor implements StepExecutorInterface {
|
||||
};
|
||||
}
|
||||
|
||||
supports(): boolean {
|
||||
supports(_stepType: string): boolean {
|
||||
// Legacy executor supports all step types via its own registry
|
||||
return true;
|
||||
}
|
||||
@@ -89,11 +100,18 @@ export class LegacyStepExecutor implements StepExecutorInterface {
|
||||
*
|
||||
* In strict mode, any unsupported step type throws an error.
|
||||
* This executor does NOT fall back to legacy - use HybridStepExecutor for fallback behavior.
|
||||
*
|
||||
* Respects ExecutionModeConfig for:
|
||||
* - skipActionsRetry: Disables ActionRegistry retry (StepRunner owns retry)
|
||||
* - skipActionsNavWait: Disables handler nav-wait (StepRunner owns nav-wait)
|
||||
*/
|
||||
export class ActionsStepExecutor implements StepExecutorInterface {
|
||||
private executor: ReturnType<typeof createStepExecutor>;
|
||||
|
||||
constructor(private registry: ActionRegistry) {
|
||||
constructor(
|
||||
private registry: ActionRegistry,
|
||||
private config: ExecutionModeConfig,
|
||||
) {
|
||||
this.executor = createStepExecutor(registry);
|
||||
}
|
||||
|
||||
@@ -108,6 +126,9 @@ export class ActionsStepExecutor implements StepExecutorInterface {
|
||||
runId: options.runId,
|
||||
pushLog: options.pushLog,
|
||||
strict: true,
|
||||
// Pass policy skip flags from config (default to true = skip)
|
||||
skipRetry: this.config.skipActionsRetry !== false,
|
||||
skipNavWait: this.config.skipActionsNavWait !== false,
|
||||
})) as StepExecutionAttempt;
|
||||
|
||||
// With strict=true, we should never get { supported: false } - it would throw instead
|
||||
@@ -130,6 +151,12 @@ export class ActionsStepExecutor implements StepExecutorInterface {
|
||||
|
||||
/**
|
||||
* Hybrid step executor that tries actions first, falls back to legacy
|
||||
*
|
||||
* Respects ExecutionModeConfig for:
|
||||
* - actionsAllowlist/legacyOnlyTypes: Controls which steps use actions vs legacy
|
||||
* - skipActionsRetry: Disables ActionRegistry retry (StepRunner owns retry)
|
||||
* - skipActionsNavWait: Disables handler nav-wait (StepRunner owns nav-wait)
|
||||
* - logFallbacks: Whether to log when falling back to legacy
|
||||
*/
|
||||
export class HybridStepExecutor implements StepExecutorInterface {
|
||||
private actionsExecutor: ReturnType<typeof createStepExecutor>;
|
||||
@@ -161,6 +188,9 @@ export class HybridStepExecutor implements StepExecutorInterface {
|
||||
runId: options.runId,
|
||||
pushLog: options.pushLog,
|
||||
strict: false, // Don't throw on unsupported, return { supported: false }
|
||||
// Pass policy skip flags from config (default to true = skip)
|
||||
skipRetry: this.config.skipActionsRetry !== false,
|
||||
skipNavWait: this.config.skipActionsNavWait !== false,
|
||||
})) as StepExecutionAttempt;
|
||||
|
||||
if (attempt.supported) {
|
||||
@@ -209,7 +239,7 @@ export function createExecutor(
|
||||
if (!registry) {
|
||||
throw new Error('ActionRegistry required for actions execution mode');
|
||||
}
|
||||
return new ActionsStepExecutor(registry);
|
||||
return new ActionsStepExecutor(registry, config);
|
||||
|
||||
case 'hybrid':
|
||||
if (!registry) {
|
||||
@@ -217,8 +247,8 @@ export function createExecutor(
|
||||
}
|
||||
return new HybridStepExecutor(registry, config);
|
||||
|
||||
default: // TypeScript exhaustiveness check
|
||||
{
|
||||
default: {
|
||||
// TypeScript exhaustiveness check
|
||||
const _exhaustive: never = config.mode;
|
||||
throw new Error(`Unknown execution mode: ${_exhaustive}`);
|
||||
}
|
||||
|
||||
+44
-4
@@ -1,9 +1,14 @@
|
||||
// step-runner.ts — encapsulates execution of a single step with policies and plugins
|
||||
/**
|
||||
* step-runner.ts
|
||||
*
|
||||
* Encapsulates execution of a single step with policies (retry, navigation wait) and plugins.
|
||||
* Uses dependency-injected StepExecutorInterface for actual step execution, enabling
|
||||
* seamless switching between legacy and ActionRegistry execution modes.
|
||||
*/
|
||||
|
||||
import type { Flow, Step, StepClick } from '../../types';
|
||||
import { STEP_TYPES } from 'chrome-mcp-shared';
|
||||
import type { ExecCtx, ExecResult } from '../../nodes';
|
||||
import { executeStep } from '../../nodes';
|
||||
import { RunLogger } from '../logging/run-logger';
|
||||
import { withRetry } from '../policies/retry';
|
||||
import {
|
||||
@@ -16,6 +21,7 @@ import { ENGINE_CONSTANTS } from '../constants';
|
||||
import { AfterScriptQueue } from './after-script-queue';
|
||||
import { PluginManager } from '../plugins/manager';
|
||||
import type { HookControl } from '../plugins/types';
|
||||
import type { StepExecutorInterface } from './step-executor';
|
||||
|
||||
// Narrow error-like value used for overlay reporting
|
||||
interface ErrorLike {
|
||||
@@ -28,14 +34,31 @@ function errorMessage(e: unknown): string {
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment dependencies for StepRunner.
|
||||
* Injected by Scheduler to allow flexible configuration and testing.
|
||||
*/
|
||||
export interface StepRunEnv {
|
||||
/** Unique identifier for this run */
|
||||
runId: string;
|
||||
/** The flow being executed */
|
||||
flow: Flow;
|
||||
/** Runtime variables */
|
||||
vars: Record<string, any>;
|
||||
/** Run logger for recording execution events */
|
||||
logger: RunLogger;
|
||||
/** Plugin manager for hooks (beforeStep, afterStep, onRetry, onError) */
|
||||
pluginManager: PluginManager;
|
||||
/** Queue for deferred after-scripts */
|
||||
afterScripts: AfterScriptQueue;
|
||||
getRemainingBudgetMs: () => number; // global deadline budget calculator
|
||||
/** Returns remaining time budget from global deadline (ms), Infinity if no deadline */
|
||||
getRemainingBudgetMs: () => number;
|
||||
/**
|
||||
* Step executor for actual step execution.
|
||||
* Defaults to LegacyStepExecutor if not provided (for backwards compatibility).
|
||||
* In future, Scheduler will inject ActionsStepExecutor or HybridStepExecutor.
|
||||
*/
|
||||
stepExecutor: StepExecutorInterface;
|
||||
}
|
||||
|
||||
export class StepRunner {
|
||||
@@ -81,7 +104,24 @@ export class StepRunner {
|
||||
try {
|
||||
await withRetry(
|
||||
async () => {
|
||||
const result = await executeStep(ctx, step);
|
||||
// Execute step via injected executor (legacy, actions, or hybrid)
|
||||
// tabId is expected to be set by Scheduler in ctx; fallback to active tab if missing
|
||||
let tabId = ctx.tabId;
|
||||
if (typeof tabId !== 'number') {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
tabId = tabs?.[0]?.id;
|
||||
}
|
||||
if (typeof tabId !== 'number') {
|
||||
throw new Error('No active tab found for step execution');
|
||||
}
|
||||
|
||||
const execResult = await this.env.stepExecutor.execute(ctx, step, {
|
||||
tabId,
|
||||
runId: this.env.runId,
|
||||
pushLog: (entry) => this.env.logger.push(entry as any),
|
||||
remainingBudgetMs: this.env.getRemainingBudgetMs(),
|
||||
});
|
||||
const result = execResult.result;
|
||||
const remainingBudget = this.env.getRemainingBudgetMs();
|
||||
if (step.type === STEP_TYPES.CLICK || step.type === STEP_TYPES.DBLCLICK) {
|
||||
const after = step.after ?? ({} as NonNullable<StepClick['after']>);
|
||||
|
||||
@@ -21,6 +21,15 @@ import { StepRunner } from './runners/step-runner';
|
||||
import { ControlFlowRunner } from './runners/control-flow-runner';
|
||||
import { SubflowRunner } from './runners/subflow-runner';
|
||||
import { ENGINE_CONSTANTS, LOG_STEP_IDS } from './constants';
|
||||
import {
|
||||
DEFAULT_EXECUTION_MODE_CONFIG,
|
||||
createActionsOnlyConfig,
|
||||
createHybridConfig,
|
||||
type ExecutionMode,
|
||||
type ExecutionModeConfig,
|
||||
} from './execution-mode';
|
||||
import { createExecutor, type StepExecutorInterface } from './runners/step-executor';
|
||||
import { createReplayActionRegistry } from '../actions/handlers';
|
||||
|
||||
export interface RunOptions {
|
||||
tabTarget?: 'current' | 'new';
|
||||
@@ -32,24 +41,118 @@ export interface RunOptions {
|
||||
args?: Record<string, any>;
|
||||
startNodeId?: string;
|
||||
plugins?: RunPlugin[];
|
||||
|
||||
/**
|
||||
* Step execution mode switch.
|
||||
* - 'legacy': Use existing nodes/executeStep (default, safest)
|
||||
* - 'hybrid': Try ActionRegistry first, fall back to legacy
|
||||
* - 'actions': Use ActionRegistry exclusively (strict mode)
|
||||
*/
|
||||
executionMode?: ExecutionMode;
|
||||
|
||||
/**
|
||||
* Hybrid mode only: allowlist of step types executed via ActionRegistry.
|
||||
* - undefined: use MINIMAL_HYBRID_ACTION_TYPES (safest default)
|
||||
* - []: disable allowlist, fall back to MIGRATED_ACTION_TYPES policy
|
||||
* - ['fill', 'key', ...]: only these types use actions
|
||||
*/
|
||||
actionsAllowlist?: string[];
|
||||
|
||||
/**
|
||||
* Hybrid mode only: denylist of step types forced to legacy.
|
||||
* When omitted, createHybridConfig defaults to LEGACY_ONLY_TYPES.
|
||||
*/
|
||||
legacyOnlyTypes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for ExecutionMode
|
||||
*/
|
||||
function isExecutionMode(value: unknown): value is ExecutionMode {
|
||||
return value === 'legacy' || value === 'hybrid' || value === 'actions';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array to Set<string>, filtering invalid values
|
||||
*/
|
||||
function toStringSet(value: unknown): Set<string> {
|
||||
const result = new Set<string>();
|
||||
if (!Array.isArray(value)) return result;
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) result.add(trimmed);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ExecutionModeConfig from RunOptions.
|
||||
* Defaults to legacy mode if executionMode is not specified.
|
||||
*
|
||||
* Note: Only array inputs for actionsAllowlist/legacyOnlyTypes are accepted.
|
||||
* Non-array values are ignored to prevent accidental misconfiguration
|
||||
* (e.g., passing a string instead of array would unexpectedly widen the allowlist).
|
||||
*/
|
||||
function buildExecutionModeConfig(options: RunOptions): ExecutionModeConfig {
|
||||
const mode: ExecutionMode = isExecutionMode(options.executionMode)
|
||||
? options.executionMode
|
||||
: DEFAULT_EXECUTION_MODE_CONFIG.mode;
|
||||
|
||||
if (mode === 'hybrid') {
|
||||
const overrides: Partial<ExecutionModeConfig> = {};
|
||||
// Only apply override if it's a valid array
|
||||
// This prevents misconfiguration from widening the actions scope
|
||||
if (Array.isArray(options.actionsAllowlist)) {
|
||||
overrides.actionsAllowlist = toStringSet(options.actionsAllowlist);
|
||||
}
|
||||
if (Array.isArray(options.legacyOnlyTypes)) {
|
||||
overrides.legacyOnlyTypes = toStringSet(options.legacyOnlyTypes);
|
||||
}
|
||||
return createHybridConfig(overrides);
|
||||
}
|
||||
|
||||
if (mode === 'actions') {
|
||||
return createActionsOnlyConfig();
|
||||
}
|
||||
|
||||
// Default: legacy mode
|
||||
return { ...DEFAULT_EXECUTION_MODE_CONFIG };
|
||||
}
|
||||
|
||||
/**
|
||||
* ExecutionOrchestrator manages the lifecycle of a flow execution.
|
||||
*
|
||||
* Architecture:
|
||||
* - Creates StepExecutor based on ExecutionModeConfig (legacy by default)
|
||||
* - Injects StepExecutor into StepRunner for step execution
|
||||
* - Manages tabId and passes it through ExecCtx
|
||||
* - Handles DAG traversal, control flow, and cleanup
|
||||
*/
|
||||
class ExecutionOrchestrator {
|
||||
// moved to ENGINE_CONSTANTS.MAX_ITERATIONS
|
||||
private runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
private startAt = Date.now();
|
||||
private logger = new RunLogger(this.runId);
|
||||
// Initialized in constructor to avoid using `this.options` before it's set
|
||||
private pluginManager: PluginManager;
|
||||
private readonly runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
private readonly startAt = Date.now();
|
||||
private readonly logger = new RunLogger(this.runId);
|
||||
private readonly pluginManager: PluginManager;
|
||||
private readonly afterScripts = new AfterScriptQueue(this.logger);
|
||||
|
||||
// Execution mode configuration (defaults to legacy for safety)
|
||||
private readonly executionModeConfig: ExecutionModeConfig;
|
||||
private readonly stepExecutor: StepExecutorInterface;
|
||||
|
||||
// Runtime state
|
||||
private vars: Record<string, any> = Object.create(null);
|
||||
private tabId: number | null = null;
|
||||
private deadline = 0;
|
||||
private networkCaptureStarted = false;
|
||||
private paused = false;
|
||||
private failed = 0;
|
||||
private executed = 0; // Count of actually executed steps (not skipped/trigger)
|
||||
private executed = 0;
|
||||
private steps: Step[] = [];
|
||||
private prepareError: RunResult | null = null;
|
||||
private afterScripts = new AfterScriptQueue(this.logger);
|
||||
|
||||
// Runners
|
||||
private stepRunner: StepRunner;
|
||||
private controlFlowRunner!: ControlFlowRunner;
|
||||
private subflowRunner!: SubflowRunner;
|
||||
@@ -58,13 +161,32 @@ class ExecutionOrchestrator {
|
||||
private flow: Flow,
|
||||
private options: RunOptions = {},
|
||||
) {
|
||||
for (const v of flow.variables || []) if (v.default !== undefined) this.vars[v.key] = v.default;
|
||||
// Initialize variables from flow defaults and args
|
||||
for (const v of flow.variables || []) {
|
||||
if (v.default !== undefined) this.vars[v.key] = v.default;
|
||||
}
|
||||
if (options.args) Object.assign(this.vars, options.args);
|
||||
|
||||
// Set up global deadline
|
||||
const globalTimeout = Math.max(0, Number(options.timeoutMs || 0));
|
||||
this.deadline = globalTimeout > 0 ? this.startAt + globalTimeout : 0;
|
||||
|
||||
// Initialize plugin manager
|
||||
this.pluginManager = new PluginManager(
|
||||
options.plugins && options.plugins.length ? options.plugins : [breakpointPlugin()],
|
||||
);
|
||||
|
||||
// Create step executor based on execution mode configuration
|
||||
// Default to legacy mode for maximum safety during migration
|
||||
this.executionModeConfig = buildExecutionModeConfig(options);
|
||||
|
||||
// Only create ActionRegistry when needed (hybrid or actions mode)
|
||||
// This avoids unnecessary initialization overhead in legacy mode
|
||||
const registry =
|
||||
this.executionModeConfig.mode === 'legacy' ? undefined : createReplayActionRegistry();
|
||||
this.stepExecutor = createExecutor(this.executionModeConfig, registry);
|
||||
|
||||
// Initialize step runner with injected executor
|
||||
this.stepRunner = new StepRunner({
|
||||
runId: this.runId,
|
||||
flow: this.flow,
|
||||
@@ -74,6 +196,7 @@ class ExecutionOrchestrator {
|
||||
afterScripts: this.afterScripts,
|
||||
getRemainingBudgetMs: () =>
|
||||
this.deadline > 0 ? Math.max(0, this.deadline - Date.now()) : Number.POSITIVE_INFINITY,
|
||||
stepExecutor: this.stepExecutor,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +244,8 @@ class ExecutionOrchestrator {
|
||||
startUrl: this.options.startUrl || derivedStartUrl,
|
||||
refresh: this.options.refresh,
|
||||
});
|
||||
// Capture tabId for use in ExecCtx
|
||||
this.tabId = ensured?.tabId ?? null;
|
||||
|
||||
// register run state
|
||||
await runState.restore();
|
||||
@@ -426,7 +551,14 @@ class ExecutionOrchestrator {
|
||||
? this.options.startNodeId
|
||||
: findFirstExecutableRoot();
|
||||
let guard = 0;
|
||||
const ctx: ExecCtx = { vars: this.vars, logger: (e: RunLogEntry) => this.logger.push(e) };
|
||||
|
||||
// Create execution context with tabId from ensureTab
|
||||
// tabId is managed by Scheduler and may be updated by openTab/switchTab actions
|
||||
const ctx: ExecCtx = {
|
||||
vars: this.vars,
|
||||
tabId: this.tabId ?? undefined,
|
||||
logger: (e: RunLogEntry) => this.logger.push(e),
|
||||
};
|
||||
if (currentId) {
|
||||
try {
|
||||
await this.logger.overlayAppend(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Flow, RunRecord, NodeBase, Edge } from './types';
|
||||
import { stepsToDAG, type RRNode, type RREdge } from 'chrome-mcp-shared';
|
||||
import { NODE_TYPES } from '@/common/node-types';
|
||||
import { IndexedDbStorage } from './storage/indexeddb-manager';
|
||||
import { IndexedDbStorage, ensureMigratedFromLocal } from './storage/indexeddb-manager';
|
||||
|
||||
// design note: simple local storage backed store for flows and run records
|
||||
// Design note: IndexedDB-backed store for flows and run records.
|
||||
// Includes lazy migration from chrome.storage.local for backwards compatibility.
|
||||
|
||||
// Validate if a type string is a valid NodeType
|
||||
const VALID_NODE_TYPES = new Set<string>(Object.values(NODE_TYPES));
|
||||
@@ -42,10 +43,22 @@ function filterValidEdges(edges: Edge[], nodeIds: Set<string>): Edge[] {
|
||||
* Normalize flow before saving: ensure nodes/edges exist for scheduler compatibility.
|
||||
* Only generates DAG from steps if nodes are missing or empty.
|
||||
* Preserves existing nodes/edges to avoid overwriting user edits.
|
||||
*
|
||||
* Also validates edges: removes edges referencing non-existent nodes to prevent
|
||||
* runtime errors in scheduler's topoOrder calculation.
|
||||
*/
|
||||
function normalizeFlowForSave(flow: Flow): Flow {
|
||||
const hasNodes = Array.isArray(flow.nodes) && flow.nodes.length > 0;
|
||||
if (hasNodes) {
|
||||
// Validate edges even when nodes exist (e.g., imported flows may have invalid edges)
|
||||
const nodeIds = new Set(flow.nodes!.map((n) => n.id));
|
||||
if (Array.isArray(flow.edges) && flow.edges.length > 0) {
|
||||
const validEdges = filterValidEdges(flow.edges, nodeIds);
|
||||
if (validEdges.length !== flow.edges.length) {
|
||||
// Some edges were invalid, return cleaned flow
|
||||
return { ...flow, edges: validEdges };
|
||||
}
|
||||
}
|
||||
return flow;
|
||||
}
|
||||
|
||||
@@ -115,6 +128,7 @@ async function lazyNormalize(flow: Flow): Promise<Flow> {
|
||||
}
|
||||
|
||||
export async function listFlows(): Promise<Flow[]> {
|
||||
await ensureMigratedFromLocal();
|
||||
const flows = await IndexedDbStorage.flows.list();
|
||||
// Check if any flows need normalization
|
||||
const needsNorm = flows.some(needsNormalization);
|
||||
@@ -134,6 +148,7 @@ export async function listFlows(): Promise<Flow[]> {
|
||||
}
|
||||
|
||||
export async function getFlow(flowId: string): Promise<Flow | undefined> {
|
||||
await ensureMigratedFromLocal();
|
||||
const flow = await IndexedDbStorage.flows.get(flowId);
|
||||
if (!flow) return undefined;
|
||||
// Lazy normalize if needed
|
||||
@@ -144,19 +159,23 @@ export async function getFlow(flowId: string): Promise<Flow | undefined> {
|
||||
}
|
||||
|
||||
export async function saveFlow(flow: Flow): Promise<void> {
|
||||
await ensureMigratedFromLocal();
|
||||
const normalizedFlow = normalizeFlowForSave(flow);
|
||||
await IndexedDbStorage.flows.save(normalizedFlow);
|
||||
}
|
||||
|
||||
export async function deleteFlow(flowId: string): Promise<void> {
|
||||
await ensureMigratedFromLocal();
|
||||
await IndexedDbStorage.flows.delete(flowId);
|
||||
}
|
||||
|
||||
export async function listRuns(): Promise<RunRecord[]> {
|
||||
await ensureMigratedFromLocal();
|
||||
return await IndexedDbStorage.runs.list();
|
||||
}
|
||||
|
||||
export async function appendRun(record: RunRecord): Promise<void> {
|
||||
await ensureMigratedFromLocal();
|
||||
const runs = await IndexedDbStorage.runs.list();
|
||||
runs.push(record);
|
||||
// Trim to keep last 10 runs per flowId to avoid unbounded growth
|
||||
@@ -181,10 +200,12 @@ export async function appendRun(record: RunRecord): Promise<void> {
|
||||
}
|
||||
|
||||
export async function listPublished(): Promise<PublishedFlowInfo[]> {
|
||||
await ensureMigratedFromLocal();
|
||||
return await IndexedDbStorage.published.list();
|
||||
}
|
||||
|
||||
export async function publishFlow(flow: Flow, slug?: string): Promise<PublishedFlowInfo> {
|
||||
await ensureMigratedFromLocal();
|
||||
const info: PublishedFlowInfo = {
|
||||
id: flow.id,
|
||||
slug: slug || toSlug(flow.name) || flow.id,
|
||||
@@ -197,6 +218,7 @@ export async function publishFlow(flow: Flow, slug?: string): Promise<PublishedF
|
||||
}
|
||||
|
||||
export async function unpublishFlow(flowId: string): Promise<void> {
|
||||
await ensureMigratedFromLocal();
|
||||
await IndexedDbStorage.published.delete(flowId);
|
||||
}
|
||||
|
||||
@@ -219,20 +241,79 @@ export async function exportAllFlows(): Promise<string> {
|
||||
return JSON.stringify({ flows }, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import flows from JSON string.
|
||||
*
|
||||
* Supported formats:
|
||||
* 1. Array of flows: [...flows]
|
||||
* 2. Object with flows array: { flows: [...] }
|
||||
* 3. Single flow with steps: { id, steps: [...] }
|
||||
* 4. Single flow with nodes (new format): { id, nodes: [...], edges?: [...] }
|
||||
*
|
||||
* Flows are normalized on save (steps → nodes if needed).
|
||||
*/
|
||||
export async function importFlowFromJson(json: string): Promise<Flow[]> {
|
||||
await ensureMigratedFromLocal();
|
||||
const parsed = JSON.parse(json);
|
||||
const flowsToImport: Flow[] = Array.isArray(parsed?.flows)
|
||||
? parsed.flows
|
||||
: parsed?.id && parsed?.steps
|
||||
? [parsed as Flow]
|
||||
: [];
|
||||
if (!flowsToImport.length) throw new Error('invalid flow json');
|
||||
|
||||
// Detect candidates from various formats
|
||||
const candidates: unknown[] = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(parsed?.flows)
|
||||
? parsed.flows
|
||||
: parsed?.id && (Array.isArray(parsed?.steps) || Array.isArray(parsed?.nodes))
|
||||
? [parsed]
|
||||
: [];
|
||||
|
||||
if (!candidates.length) {
|
||||
throw new Error('invalid flow json: no flows found');
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const flowsToImport: Flow[] = [];
|
||||
|
||||
for (const raw of candidates) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('invalid flow json: flow must be an object');
|
||||
}
|
||||
|
||||
const f = raw as Record<string, unknown>;
|
||||
const id = String(f.id || '').trim();
|
||||
if (!id) {
|
||||
throw new Error('invalid flow json: missing id');
|
||||
}
|
||||
|
||||
// Normalize fields with sensible defaults
|
||||
const name = typeof f.name === 'string' && f.name.trim() ? f.name : id;
|
||||
const version = Number.isFinite(Number(f.version)) ? Number(f.version) : 1;
|
||||
const steps = Array.isArray(f.steps) ? f.steps : [];
|
||||
|
||||
// Handle meta with proper timestamps
|
||||
const existingMeta =
|
||||
f.meta && typeof f.meta === 'object' ? (f.meta as Record<string, unknown>) : {};
|
||||
const createdAt = typeof existingMeta.createdAt === 'string' ? existingMeta.createdAt : nowIso;
|
||||
|
||||
const flow: Flow = {
|
||||
...(f as object),
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
steps,
|
||||
meta: {
|
||||
...existingMeta,
|
||||
createdAt,
|
||||
updatedAt: nowIso,
|
||||
},
|
||||
} as Flow;
|
||||
|
||||
flowsToImport.push(flow);
|
||||
}
|
||||
|
||||
// Save all flows (normalize on save)
|
||||
for (const f of flowsToImport) {
|
||||
const meta = f.meta ?? (f.meta = { createdAt: nowIso, updatedAt: nowIso } as any);
|
||||
meta.updatedAt = nowIso;
|
||||
await saveFlow(f);
|
||||
}
|
||||
|
||||
return flowsToImport;
|
||||
}
|
||||
|
||||
@@ -250,13 +331,16 @@ export interface FlowSchedule {
|
||||
}
|
||||
|
||||
export async function listSchedules(): Promise<FlowSchedule[]> {
|
||||
await ensureMigratedFromLocal();
|
||||
return await IndexedDbStorage.schedules.list();
|
||||
}
|
||||
|
||||
export async function saveSchedule(s: FlowSchedule): Promise<void> {
|
||||
await ensureMigratedFromLocal();
|
||||
await IndexedDbStorage.schedules.save(s);
|
||||
}
|
||||
|
||||
export async function removeSchedule(scheduleId: string): Promise<void> {
|
||||
await ensureMigratedFromLocal();
|
||||
await IndexedDbStorage.schedules.delete(scheduleId);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import type { RunLogEntry, Step, StepScript } from '../types';
|
||||
|
||||
/**
|
||||
* Execution context for step execution.
|
||||
* Contains runtime state that may change during flow execution.
|
||||
*/
|
||||
export interface ExecCtx {
|
||||
/** Runtime variables accessible to steps */
|
||||
vars: Record<string, any>;
|
||||
/** Logger function for recording execution events */
|
||||
logger: (e: RunLogEntry) => void;
|
||||
/**
|
||||
* Current tab ID for this execution context.
|
||||
* Managed by Scheduler, may change after openTab/switchTab actions.
|
||||
*/
|
||||
tabId?: number;
|
||||
/**
|
||||
* Current frame ID within the tab.
|
||||
* Used for iframe targeting, 0 for main frame.
|
||||
*/
|
||||
frameId?: number;
|
||||
}
|
||||
|
||||
|
||||
+200
-16
@@ -1,6 +1,7 @@
|
||||
import type { Flow, Step, VariableDef } from '../types';
|
||||
import type { Edge, Flow, NodeBase, Step, VariableDef } from '../types';
|
||||
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import { appendSteps as appendFlowSteps } from './flow-builder';
|
||||
import { NODE_TYPES } from '@/common/node-types';
|
||||
import { mapStepToNodeConfig, stepsToDAG, EDGE_LABELS } from 'chrome-mcp-shared';
|
||||
|
||||
/**
|
||||
* Recording status state machine:
|
||||
@@ -22,6 +23,9 @@ export interface RecordingSessionState {
|
||||
stoppedTabs: Set<number>;
|
||||
}
|
||||
|
||||
// Valid node types for type checking
|
||||
const VALID_NODE_TYPES = new Set<string>(Object.values(NODE_TYPES));
|
||||
|
||||
export class RecordingSessionManager {
|
||||
private state: RecordingSessionState = {
|
||||
sessionId: '',
|
||||
@@ -32,6 +36,12 @@ export class RecordingSessionManager {
|
||||
stoppedTabs: new Set<number>(),
|
||||
};
|
||||
|
||||
// Session-level caches for incremental DAG sync (cleared on session start/stop)
|
||||
private stepIndexMap: Map<string, number> = new Map();
|
||||
private nodeIndexMap: Map<string, number> = new Map();
|
||||
// Monotonic counter for edge id generation (avoids collision on delete/reorder)
|
||||
private edgeSeq: number = 0;
|
||||
|
||||
getStatus(): RecordingStatus {
|
||||
return this.state.status;
|
||||
}
|
||||
@@ -61,6 +71,11 @@ export class RecordingSessionManager {
|
||||
}
|
||||
|
||||
async startSession(flow: Flow, originTabId: number): Promise<void> {
|
||||
// Clear caches for fresh session
|
||||
this.stepIndexMap.clear();
|
||||
this.nodeIndexMap.clear();
|
||||
this.edgeSeq = 0;
|
||||
|
||||
this.state = {
|
||||
sessionId: `sess_${Date.now()}`,
|
||||
status: 'recording',
|
||||
@@ -69,6 +84,9 @@ export class RecordingSessionManager {
|
||||
activeTabs: new Set<number>([originTabId]),
|
||||
stoppedTabs: new Set<number>(),
|
||||
};
|
||||
|
||||
// Initialize caches from existing flow data (supports resume scenarios)
|
||||
this.rebuildCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +157,10 @@ export class RecordingSessionManager {
|
||||
this.state.originTabId = null;
|
||||
this.state.activeTabs.clear();
|
||||
this.state.stoppedTabs.clear();
|
||||
// Clear caches
|
||||
this.stepIndexMap.clear();
|
||||
this.nodeIndexMap.clear();
|
||||
this.edgeSeq = 0;
|
||||
return flow;
|
||||
}
|
||||
|
||||
@@ -154,43 +176,95 @@ export class RecordingSessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append or upsert steps to the flow.
|
||||
* Append or upsert steps to the flow with incremental DAG sync.
|
||||
* Uses upsert semantics: if a step with the same id exists, update it in place.
|
||||
* This ensures fill steps get their final value even after initial flush.
|
||||
*
|
||||
* DAG sync: maintains flow.nodes/edges in lockstep with flow.steps during recording.
|
||||
* - New step → create node + edge from previous node
|
||||
* - Upsert step → update node.config and node.type
|
||||
* - Invariant violation → fallback to full stepsToDAG rebuild
|
||||
*/
|
||||
appendSteps(steps: Step[]): void {
|
||||
const f = this.state.flow;
|
||||
if (!f || !Array.isArray(steps) || steps.length === 0) return;
|
||||
|
||||
// Ensure steps array exists
|
||||
if (!f.steps) {
|
||||
f.steps = [];
|
||||
// Initialize arrays if missing
|
||||
if (!Array.isArray(f.steps)) f.steps = [];
|
||||
if (!Array.isArray(f.nodes)) f.nodes = [];
|
||||
if (!Array.isArray(f.edges)) f.edges = [];
|
||||
|
||||
const nodes = f.nodes;
|
||||
const edges = f.edges;
|
||||
|
||||
// Check invariants: nodes must be 1:1 with steps, edges must match linear chain
|
||||
// If violated (e.g., imported flow, manual edit), rebuild DAG
|
||||
if (!this.checkDagInvariant(f.steps, nodes, edges)) {
|
||||
this.rebuildDag();
|
||||
}
|
||||
|
||||
// Build a map of existing step ids for fast lookup
|
||||
const existingStepMap = new Map<string, number>();
|
||||
f.steps.forEach((s, idx) => {
|
||||
if (s.id) existingStepMap.set(s.id, idx);
|
||||
});
|
||||
|
||||
// Process each incoming step with upsert semantics
|
||||
// Process each incoming step with upsert semantics + incremental DAG sync
|
||||
let needsRebuild = false;
|
||||
for (const step of steps) {
|
||||
// Ensure step has an id
|
||||
if (!step.id) {
|
||||
step.id = `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
const existingIdx = existingStepMap.get(step.id);
|
||||
const existingIdx = this.stepIndexMap.get(step.id);
|
||||
if (existingIdx !== undefined) {
|
||||
// Upsert: update existing step in place (preserves order)
|
||||
// Upsert: update existing step in place
|
||||
f.steps[existingIdx] = step;
|
||||
|
||||
// Sync node: update config and type
|
||||
const nodeIdx = this.nodeIndexMap.get(step.id);
|
||||
if (nodeIdx === undefined || !nodes[nodeIdx]) {
|
||||
needsRebuild = true;
|
||||
continue;
|
||||
}
|
||||
nodes[nodeIdx] = {
|
||||
...nodes[nodeIdx],
|
||||
type: this.toNodeType(step.type),
|
||||
config: mapStepToNodeConfig(step),
|
||||
};
|
||||
} else {
|
||||
// Append: new step
|
||||
const prevStepId = f.steps.length > 0 ? f.steps[f.steps.length - 1]?.id : undefined;
|
||||
|
||||
f.steps.push(step);
|
||||
existingStepMap.set(step.id, f.steps.length - 1);
|
||||
this.stepIndexMap.set(step.id, f.steps.length - 1);
|
||||
|
||||
// Create corresponding node
|
||||
const newNode: NodeBase = {
|
||||
id: step.id,
|
||||
type: this.toNodeType(step.type),
|
||||
config: mapStepToNodeConfig(step),
|
||||
};
|
||||
nodes.push(newNode);
|
||||
this.nodeIndexMap.set(step.id, nodes.length - 1);
|
||||
|
||||
// Create edge from previous node (if exists)
|
||||
if (prevStepId) {
|
||||
if (!this.nodeIndexMap.has(prevStepId)) {
|
||||
needsRebuild = true;
|
||||
continue;
|
||||
}
|
||||
const edgeId = `e_${this.edgeSeq++}_${prevStepId}_${step.id}`;
|
||||
edges.push({
|
||||
id: edgeId,
|
||||
from: prevStepId,
|
||||
to: step.id,
|
||||
label: EDGE_LABELS.DEFAULT,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final invariant check: if any inconsistency detected, rebuild
|
||||
if (needsRebuild || !this.checkDagInvariant(f.steps, nodes, edges)) {
|
||||
this.rebuildDag();
|
||||
}
|
||||
|
||||
// Update meta timestamp
|
||||
try {
|
||||
if (f.meta) {
|
||||
@@ -203,6 +277,116 @@ export class RecordingSessionManager {
|
||||
this.broadcastTimelineUpdate(steps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert step type to valid NodeType with fallback to SCRIPT.
|
||||
* Logs a warning for unknown types to help detect upstream type drift.
|
||||
*/
|
||||
private toNodeType(stepType: string): NodeBase['type'] {
|
||||
if (VALID_NODE_TYPES.has(stepType)) {
|
||||
return stepType as NodeBase['type'];
|
||||
}
|
||||
console.warn(`[RecordingSession] Unknown step type "${stepType}", falling back to "script"`);
|
||||
return NODE_TYPES.SCRIPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check DAG invariant for linear recording:
|
||||
* - nodes.length === steps.length
|
||||
* - edges.length === max(0, steps.length - 1)
|
||||
* - Last edge (if exists) points to the last step
|
||||
*/
|
||||
private checkDagInvariant(steps: Step[], nodes: NodeBase[], edges: Edge[]): boolean {
|
||||
const stepCount = steps.length;
|
||||
const expectedEdgeCount = Math.max(0, stepCount - 1);
|
||||
|
||||
// Check node count matches step count
|
||||
if (nodes.length !== stepCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check edge count matches expected linear chain
|
||||
if (edges.length !== expectedEdgeCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check last edge points to last step (if edges exist)
|
||||
if (edges.length > 0 && steps.length > 0) {
|
||||
const lastEdge = edges[edges.length - 1];
|
||||
const lastStepId = steps[steps.length - 1]?.id;
|
||||
if (lastEdge.to !== lastStepId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild caches from current flow state.
|
||||
* Called on session start and after DAG rebuild.
|
||||
*/
|
||||
private rebuildCaches(): void {
|
||||
const f = this.state.flow;
|
||||
if (!f) return;
|
||||
|
||||
this.stepIndexMap.clear();
|
||||
this.nodeIndexMap.clear();
|
||||
|
||||
if (Array.isArray(f.steps)) {
|
||||
for (let i = 0; i < f.steps.length; i++) {
|
||||
const id = f.steps[i]?.id;
|
||||
if (id) this.stepIndexMap.set(id, i);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(f.nodes)) {
|
||||
for (let i = 0; i < f.nodes.length; i++) {
|
||||
const id = f.nodes[i]?.id;
|
||||
if (id) this.nodeIndexMap.set(id, i);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync edgeSeq to continue from current edge count (avoids id collision)
|
||||
this.edgeSeq = Array.isArray(f.edges) ? f.edges.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full DAG rebuild from steps. Used as fallback when invariants are violated.
|
||||
* Clears existing nodes/edges and regenerates from scratch.
|
||||
*/
|
||||
private rebuildDag(): void {
|
||||
const f = this.state.flow;
|
||||
if (!f || !Array.isArray(f.steps)) return;
|
||||
|
||||
const dag = stepsToDAG(f.steps);
|
||||
|
||||
// Clear and repopulate nodes
|
||||
if (!Array.isArray(f.nodes)) f.nodes = [];
|
||||
f.nodes.length = 0;
|
||||
for (const n of dag.nodes) {
|
||||
f.nodes.push({
|
||||
id: n.id,
|
||||
type: this.toNodeType(n.type),
|
||||
config: n.config,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear and repopulate edges
|
||||
if (!Array.isArray(f.edges)) f.edges = [];
|
||||
f.edges.length = 0;
|
||||
for (const e of dag.edges) {
|
||||
f.edges.push({
|
||||
id: e.id,
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
label: e.label,
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild caches
|
||||
this.rebuildCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append variables to the flow. Deduplicates by key.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IndexedDbStorage } from './storage/indexeddb-manager';
|
||||
import { IndexedDbStorage, ensureMigratedFromLocal } from './storage/indexeddb-manager';
|
||||
|
||||
export type TriggerType = 'url' | 'contextMenu' | 'command' | 'dom';
|
||||
|
||||
@@ -37,14 +37,17 @@ export interface DomTrigger extends BaseTrigger {
|
||||
export type FlowTrigger = UrlTrigger | ContextMenuTrigger | CommandTrigger | DomTrigger;
|
||||
|
||||
export async function listTriggers(): Promise<FlowTrigger[]> {
|
||||
await ensureMigratedFromLocal();
|
||||
return await IndexedDbStorage.triggers.list();
|
||||
}
|
||||
|
||||
export async function saveTrigger(t: FlowTrigger): Promise<void> {
|
||||
await ensureMigratedFromLocal();
|
||||
await IndexedDbStorage.triggers.save(t);
|
||||
}
|
||||
|
||||
export async function deleteTrigger(id: string): Promise<void> {
|
||||
await ensureMigratedFromLocal();
|
||||
await IndexedDbStorage.triggers.delete(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -361,6 +361,13 @@ class ComputerTool extends BaseBrowserToolExecutor {
|
||||
try {
|
||||
if (params.ref) {
|
||||
await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
|
||||
// Scroll element into view first to ensure it's visible
|
||||
try {
|
||||
await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: params.ref });
|
||||
} catch {
|
||||
// Best effort - continue even if scroll fails
|
||||
}
|
||||
// Re-resolve coordinates after scroll
|
||||
const resolved = await this.sendMessageToTab(tab.id, {
|
||||
action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
|
||||
ref: params.ref,
|
||||
@@ -378,7 +385,27 @@ class ComputerTool extends BaseBrowserToolExecutor {
|
||||
isXPath: selectorType === 'xpath',
|
||||
});
|
||||
if (ensured && ensured.success) {
|
||||
coord = project({ x: ensured.center.x, y: ensured.center.y });
|
||||
// Scroll element into view first to ensure it's visible
|
||||
const resolvedRef = typeof ensured.ref === 'string' ? ensured.ref : undefined;
|
||||
if (resolvedRef) {
|
||||
try {
|
||||
await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: resolvedRef });
|
||||
} catch {
|
||||
// Best effort - continue even if scroll fails
|
||||
}
|
||||
// Re-resolve coordinates after scroll
|
||||
const reResolved = await this.sendMessageToTab(tab.id, {
|
||||
action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
|
||||
ref: resolvedRef,
|
||||
});
|
||||
if (reResolved && reResolved.success) {
|
||||
coord = project({ x: reResolved.center.x, y: reResolved.center.y });
|
||||
} else {
|
||||
coord = project({ x: ensured.center.x, y: ensured.center.y });
|
||||
}
|
||||
} else {
|
||||
coord = project({ x: ensured.center.x, y: ensured.center.y });
|
||||
}
|
||||
resolvedBy = 'selector';
|
||||
}
|
||||
} else if (params.coordinates) {
|
||||
|
||||
@@ -60,6 +60,8 @@ interface ConsoleResult {
|
||||
messageCount: number;
|
||||
exceptionCount: number;
|
||||
messageLimitReached: boolean;
|
||||
droppedMessageCount: number;
|
||||
droppedExceptionCount: number;
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
@@ -269,6 +271,8 @@ class ConsoleTool extends BaseBrowserToolExecutor {
|
||||
messageCount: read.messageCount,
|
||||
exceptionCount: read.exceptionCount,
|
||||
messageLimitReached: read.messageLimitReached,
|
||||
droppedMessageCount: read.droppedMessageCount,
|
||||
droppedExceptionCount: read.droppedExceptionCount,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -611,6 +615,8 @@ class ConsoleTool extends BaseBrowserToolExecutor {
|
||||
messageCount: messages.length,
|
||||
exceptionCount: exceptions.length,
|
||||
messageLimitReached: limitReached,
|
||||
droppedMessageCount: 0,
|
||||
droppedExceptionCount: 0,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`ConsoleTool: Error capturing console messages for tab ${tabId}:`, error);
|
||||
|
||||
@@ -441,6 +441,21 @@ async function stopRecording(): Promise<GifResult> {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Best-effort final frame capture to preserve end state
|
||||
try {
|
||||
const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx);
|
||||
await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {
|
||||
imageData: Array.from(frameData),
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
delay: state.frameDelayCs,
|
||||
maxColors: state.maxColors,
|
||||
});
|
||||
state.frameCount += 1;
|
||||
} catch (error) {
|
||||
console.warn('GIF recorder: Final frame capture error (non-fatal):', error);
|
||||
}
|
||||
|
||||
const frameCount = state.frameCount;
|
||||
const durationMs = Date.now() - state.startTime;
|
||||
const filename = state.filename;
|
||||
|
||||
@@ -5,6 +5,8 @@ export { screenshotTool } from './screenshot';
|
||||
export { webFetcherTool, getInteractiveElementsTool } from './web-fetcher';
|
||||
export { clickTool, fillTool } from './interaction';
|
||||
export { networkRequestTool } from './network-request';
|
||||
export { networkCaptureTool } from './network-capture';
|
||||
// Legacy exports (for internal use by networkCaptureTool)
|
||||
export { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger';
|
||||
export { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request';
|
||||
export { keyboardTool } from './keyboard';
|
||||
|
||||
+20
-142
@@ -2,6 +2,7 @@ import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { cdpSessionManager } from '@/utils/cdp-session-manager';
|
||||
import { NETWORK_FILTERS } from '@/common/constants';
|
||||
|
||||
interface NetworkDebuggerStartToolParams {
|
||||
url?: string; // URL to navigate to or focus. If not provided, uses active tab.
|
||||
@@ -35,80 +36,6 @@ interface NetworkRequestInfo {
|
||||
[key: string]: any; // Allow other properties from debugger events
|
||||
}
|
||||
|
||||
// Static resource file extensions list
|
||||
const STATIC_RESOURCE_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.webp',
|
||||
'.svg',
|
||||
'.ico',
|
||||
'.cur',
|
||||
'.css',
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.ttf',
|
||||
'.eot',
|
||||
'.otf',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.avi',
|
||||
'.mov',
|
||||
'.webm',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.pdf',
|
||||
'.zip',
|
||||
'.rar',
|
||||
'.7z',
|
||||
'.iso',
|
||||
'.dmg',
|
||||
'.js',
|
||||
'.jsx',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.map', // Source maps
|
||||
];
|
||||
|
||||
// Ad and analytics domains list
|
||||
const AD_ANALYTICS_DOMAINS = [
|
||||
'google-analytics.com',
|
||||
'googletagmanager.com',
|
||||
'analytics.google.com',
|
||||
'doubleclick.net',
|
||||
'googlesyndication.com',
|
||||
'googleads.g.doubleclick.net',
|
||||
'facebook.com/tr',
|
||||
'connect.facebook.net',
|
||||
'bat.bing.com',
|
||||
'linkedin.com', // Often for tracking pixels/insights
|
||||
'analytics.twitter.com',
|
||||
'static.hotjar.com',
|
||||
'script.hotjar.com',
|
||||
'stats.g.doubleclick.net',
|
||||
'amazon-adsystem.com',
|
||||
'adservice.google.com',
|
||||
'pagead2.googlesyndication.com',
|
||||
'ads-twitter.com',
|
||||
'ads.yahoo.com',
|
||||
'adroll.com',
|
||||
'adnxs.com',
|
||||
'criteo.com',
|
||||
'quantserve.com',
|
||||
'scorecardresearch.com',
|
||||
'segment.io',
|
||||
'amplitude.com',
|
||||
'mixpanel.com',
|
||||
'optimizely.com',
|
||||
'crazyegg.com',
|
||||
'clicktale.net',
|
||||
'mouseflow.com',
|
||||
'fullstory.com',
|
||||
'clarity.ms',
|
||||
];
|
||||
|
||||
const DEBUGGER_PROTOCOL_VERSION = '1.3';
|
||||
const MAX_RESPONSE_BODY_SIZE_BYTES = 1 * 1024 * 1024; // 1MB
|
||||
const DEFAULT_MAX_CAPTURE_TIME_MS = 3 * 60 * 1000; // 3 minutes
|
||||
@@ -366,92 +293,43 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
await this.stopCapture(tabId, true); // Pass a flag indicating it's an auto-stop
|
||||
}
|
||||
|
||||
// Static resource MIME types list (used when includeStatic is false)
|
||||
private static STATIC_MIME_TYPES_TO_FILTER = [
|
||||
'image/', // all image types (image/png, image/jpeg, etc.)
|
||||
'font/', // all font types (font/woff, font/ttf, etc.)
|
||||
'audio/', // all audio types
|
||||
'video/', // all video types
|
||||
'text/css',
|
||||
// Note: text/javascript, application/javascript etc. are often filtered by extension.
|
||||
// If script files need to be filtered by MIME type as well, add them here.
|
||||
// 'application/javascript',
|
||||
// 'application/x-javascript',
|
||||
'application/pdf',
|
||||
'application/zip',
|
||||
'application/octet-stream', // Often used for downloads or generic binary data
|
||||
];
|
||||
|
||||
// API-like response MIME types (these are generally NOT filtered, and we might want their bodies)
|
||||
private static API_MIME_TYPES = [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
// 'text/json' is not standard, but sometimes seen. 'application/json' is preferred.
|
||||
'text/plain', // Can be API response, handle with care. Often captured.
|
||||
'application/x-www-form-urlencoded', // Form submissions, can be API calls
|
||||
'application/graphql',
|
||||
// Add other common API types if needed
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if URL should be filtered based on EXCLUDED_DOMAINS patterns.
|
||||
* Uses full URL substring match to support patterns like 'facebook.com/tr'.
|
||||
*/
|
||||
private shouldFilterRequestByUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
// Filter ad/analytics domains
|
||||
if (AD_ANALYTICS_DOMAINS.some((domain) => urlObj.hostname.includes(domain))) {
|
||||
// console.log(`NetworkDebuggerStartTool: Filtering ad/analytics domain: ${urlObj.hostname}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
// Invalid URL? Log and don't filter.
|
||||
console.error(`NetworkDebuggerStartTool: Error parsing URL for filtering: ${url}`, e);
|
||||
return false;
|
||||
}
|
||||
const normalizedUrl = String(url || '').toLowerCase();
|
||||
if (!normalizedUrl) return false;
|
||||
return NETWORK_FILTERS.EXCLUDED_DOMAINS.some((pattern) => normalizedUrl.includes(pattern));
|
||||
}
|
||||
|
||||
private shouldFilterRequestByExtension(url: string, includeStatic: boolean): boolean {
|
||||
if (includeStatic) return false; // If including static, don't filter by extension
|
||||
if (includeStatic) return false;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const path = urlObj.pathname.toLowerCase();
|
||||
if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) {
|
||||
// console.log(`NetworkDebuggerStartTool: Filtering static resource by extension: ${path}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`NetworkDebuggerStartTool: Error parsing URL for extension filtering: ${url}`,
|
||||
e,
|
||||
);
|
||||
return NETWORK_FILTERS.STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// MIME type-based filtering, called after response is received
|
||||
private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean {
|
||||
if (!mimeType) return false; // No MIME type, don't make a decision based on it here
|
||||
if (!mimeType) return false;
|
||||
|
||||
// If API_MIME_TYPES contains this mimeType, we explicitly DON'T want to filter it by MIME.
|
||||
if (NetworkDebuggerStartTool.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) {
|
||||
// Never filter API MIME types
|
||||
if (NETWORK_FILTERS.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we are NOT including static files, then check against the list of static MIME types.
|
||||
// Filter static MIME types when not including static resources
|
||||
if (!includeStatic) {
|
||||
if (
|
||||
NetworkDebuggerStartTool.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>
|
||||
mimeType.startsWith(staticMime),
|
||||
)
|
||||
) {
|
||||
// console.log(`NetworkDebuggerStartTool: Filtering static resource by MIME type: ${mimeType}`);
|
||||
return true;
|
||||
}
|
||||
return NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>
|
||||
mimeType.startsWith(staticMime),
|
||||
);
|
||||
}
|
||||
|
||||
// Default: don't filter by MIME type if no other rule matched
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -595,7 +473,7 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
const mimeType = requestInfo.mimeType || '';
|
||||
|
||||
// Prioritize API MIME types for body capture
|
||||
if (NetworkDebuggerStartTool.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {
|
||||
if (NETWORK_FILTERS.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -611,7 +489,7 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
// unless it's a known non-API MIME type that slipped through (e.g. a script from a /api/ path)
|
||||
if (
|
||||
mimeType &&
|
||||
NetworkDebuggerStartTool.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>
|
||||
NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>
|
||||
mimeType.startsWith(staticMime),
|
||||
)
|
||||
) {
|
||||
|
||||
+21
-16
@@ -202,31 +202,31 @@ class NetworkCaptureStartTool extends BaseBrowserToolExecutor {
|
||||
|
||||
/**
|
||||
* Determine whether a request should be filtered (based on URL)
|
||||
* Uses full URL substring match to support patterns like 'facebook.com/tr'
|
||||
*/
|
||||
private shouldFilterRequest(url: string, includeStatic: boolean): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const normalizedUrl = String(url || '').toLowerCase();
|
||||
if (!normalizedUrl) return false;
|
||||
|
||||
// Check if it's an ad or analytics domain
|
||||
if (AD_ANALYTICS_DOMAINS.some((domain) => urlObj.hostname.includes(domain))) {
|
||||
console.log(`NetworkCaptureV2: Filtering ad/analytics domain: ${urlObj.hostname}`);
|
||||
return true;
|
||||
}
|
||||
// Check if it's an ad or analytics domain (full URL substring match)
|
||||
if (AD_ANALYTICS_DOMAINS.some((pattern) => normalizedUrl.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not including static resources, check extensions
|
||||
if (!includeStatic) {
|
||||
// If not including static resources, check extensions
|
||||
if (!includeStatic) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const path = urlObj.pathname.toLowerCase();
|
||||
if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) {
|
||||
console.log(`NetworkCaptureV2: Filtering static resource by extension: ${path}`);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('NetworkCaptureV2: Error filtering URL:', e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,9 +345,14 @@ class NetworkCaptureStartTool extends BaseBrowserToolExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up request listeners
|
||||
* Set up request listeners (idempotent - won't add duplicate listeners)
|
||||
*/
|
||||
private setupListeners(): void {
|
||||
// Skip if listeners are already set up
|
||||
if (this.listeners.onBeforeRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Before request is sent
|
||||
this.listeners.onBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => {
|
||||
const captureInfo = this.captureData.get(details.tabId);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request';
|
||||
import { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger';
|
||||
|
||||
type NetworkCaptureBackend = 'webRequest' | 'debugger';
|
||||
|
||||
interface NetworkCaptureToolParams {
|
||||
action: 'start' | 'stop';
|
||||
needResponseBody?: boolean;
|
||||
url?: string;
|
||||
maxCaptureTime?: number;
|
||||
inactivityTimeout?: number;
|
||||
includeStatic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from ToolResult
|
||||
*/
|
||||
function getFirstText(result: ToolResult): string | undefined {
|
||||
const first = result.content?.[0];
|
||||
return first && first.type === 'text' ? first.text : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate JSON result with additional fields
|
||||
*/
|
||||
function decorateJsonResult(result: ToolResult, extra: Record<string, unknown>): ToolResult {
|
||||
const text = getFirstText(result);
|
||||
if (typeof text !== 'string') return result;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return {
|
||||
...result,
|
||||
content: [{ type: 'text', text: JSON.stringify({ ...parsed, ...extra }) }],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If the underlying tool didn't return JSON, keep it as-is
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debugger-based capture is active
|
||||
*/
|
||||
function isDebuggerCaptureActive(): boolean {
|
||||
const captureData = (
|
||||
networkDebuggerStartTool as unknown as { captureData?: Map<number, unknown> }
|
||||
).captureData;
|
||||
return captureData instanceof Map && captureData.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if webRequest-based capture is active
|
||||
*/
|
||||
function isWebRequestCaptureActive(): boolean {
|
||||
return networkCaptureStartTool.captureData.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Network Capture Tool
|
||||
*
|
||||
* Provides a single entry point for network capture, automatically selecting
|
||||
* the appropriate backend based on the `needResponseBody` parameter:
|
||||
* - needResponseBody=false (default): uses webRequest API (lightweight, no debugger conflict)
|
||||
* - needResponseBody=true: uses Debugger API (captures response body, may conflict with DevTools)
|
||||
*/
|
||||
class NetworkCaptureTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE;
|
||||
|
||||
async execute(args: NetworkCaptureToolParams): Promise<ToolResult> {
|
||||
const action = args?.action;
|
||||
if (action !== 'start' && action !== 'stop') {
|
||||
return createErrorResponse('Parameter [action] is required and must be one of: start, stop');
|
||||
}
|
||||
|
||||
const wantBody = args?.needResponseBody === true;
|
||||
const debuggerActive = isDebuggerCaptureActive();
|
||||
const webActive = isWebRequestCaptureActive();
|
||||
|
||||
if (action === 'start') {
|
||||
return this.handleStart(args, wantBody, debuggerActive, webActive);
|
||||
}
|
||||
|
||||
return this.handleStop(args, debuggerActive, webActive);
|
||||
}
|
||||
|
||||
private async handleStart(
|
||||
args: NetworkCaptureToolParams,
|
||||
wantBody: boolean,
|
||||
debuggerActive: boolean,
|
||||
webActive: boolean,
|
||||
): Promise<ToolResult> {
|
||||
// Prevent any capture conflict (cross-mode or same-mode)
|
||||
if (debuggerActive || webActive) {
|
||||
const activeMode = debuggerActive ? 'debugger' : 'webRequest';
|
||||
return createErrorResponse(
|
||||
`Network capture is already active in ${activeMode} mode. Stop it before starting a new capture.`,
|
||||
);
|
||||
}
|
||||
|
||||
const delegate = wantBody ? networkDebuggerStartTool : networkCaptureStartTool;
|
||||
const backend: NetworkCaptureBackend = wantBody ? 'debugger' : 'webRequest';
|
||||
|
||||
const result = await delegate.execute({
|
||||
url: args.url,
|
||||
maxCaptureTime: args.maxCaptureTime,
|
||||
inactivityTimeout: args.inactivityTimeout,
|
||||
includeStatic: args.includeStatic,
|
||||
});
|
||||
|
||||
return decorateJsonResult(result, { backend, needResponseBody: wantBody });
|
||||
}
|
||||
|
||||
private async handleStop(
|
||||
args: NetworkCaptureToolParams,
|
||||
debuggerActive: boolean,
|
||||
webActive: boolean,
|
||||
): Promise<ToolResult> {
|
||||
// Determine which backend to stop
|
||||
let backendToStop: NetworkCaptureBackend | null = null;
|
||||
|
||||
// If user explicitly specified needResponseBody, try to stop that specific backend
|
||||
if (args?.needResponseBody === true) {
|
||||
backendToStop = debuggerActive ? 'debugger' : null;
|
||||
} else if (args?.needResponseBody === false) {
|
||||
backendToStop = webActive ? 'webRequest' : null;
|
||||
}
|
||||
|
||||
// If no explicit preference or the specified backend isn't active, auto-detect
|
||||
if (!backendToStop) {
|
||||
if (debuggerActive) {
|
||||
backendToStop = 'debugger';
|
||||
} else if (webActive) {
|
||||
backendToStop = 'webRequest';
|
||||
}
|
||||
}
|
||||
|
||||
if (!backendToStop) {
|
||||
return createErrorResponse('No active network captures found in any tab.');
|
||||
}
|
||||
|
||||
const delegateStop =
|
||||
backendToStop === 'debugger' ? networkDebuggerStopTool : networkCaptureStopTool;
|
||||
const result = await delegateStop.execute();
|
||||
|
||||
return decorateJsonResult(result, {
|
||||
backend: backendToStop,
|
||||
needResponseBody: backendToStop === 'debugger',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const networkCaptureTool = new NetworkCaptureTool();
|
||||
@@ -31,6 +31,22 @@ export interface FloatingDragOptions {
|
||||
onPositionChange: (position: FloatingPosition) => void;
|
||||
/** Margin from viewport edges in pixels */
|
||||
clampMargin: number;
|
||||
/**
|
||||
* Delay drag activation to allow click interactions on the handle.
|
||||
*
|
||||
* When > 0, drag is only activated after:
|
||||
* - Pointer held for at least this duration (ms), OR
|
||||
* - Pointer moved beyond `moveThresholdPx`
|
||||
*
|
||||
* Use case: minimized toolbar where short click restores, long press drags.
|
||||
* @default 0 (immediate drag)
|
||||
*/
|
||||
clickThresholdMs?: number;
|
||||
/**
|
||||
* Movement threshold (px) that activates drag when clickThresholdMs > 0.
|
||||
* @default 0
|
||||
*/
|
||||
moveThresholdPx?: number;
|
||||
}
|
||||
|
||||
interface DragSession {
|
||||
@@ -40,6 +56,11 @@ interface DragSession {
|
||||
offsetY: number;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
/** Starting client coordinates for move threshold calculation */
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
/** Whether drag has been activated (always true when clickThresholdMs=0) */
|
||||
activated: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -99,8 +120,15 @@ function roundPosition(position: FloatingPosition): FloatingPosition {
|
||||
export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
const { handleEl, targetEl, onPositionChange, clampMargin } = options;
|
||||
|
||||
// Parse delayed activation options
|
||||
const clickThresholdMs = Math.max(0, options.clickThresholdMs ?? 0);
|
||||
const moveThresholdPx = Math.max(0, options.moveThresholdPx ?? 0);
|
||||
const delayedActivation = clickThresholdMs > 0;
|
||||
const moveThresholdSq = moveThresholdPx * moveThresholdPx;
|
||||
|
||||
let session: DragSession | null = null;
|
||||
let disposed = false;
|
||||
let activationTimer: number | null = null;
|
||||
|
||||
function teardownWindowListeners(): void {
|
||||
window.removeEventListener('pointermove', onWindowPointerMove, WINDOW_CAPTURE);
|
||||
@@ -111,11 +139,20 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}
|
||||
|
||||
function clearActivationTimer(): void {
|
||||
if (activationTimer !== null) {
|
||||
window.clearTimeout(activationTimer);
|
||||
activationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag(pointerId: number): void {
|
||||
const s = session;
|
||||
if (!s) return;
|
||||
if (s.pointerId !== pointerId) return;
|
||||
|
||||
clearActivationTimer();
|
||||
|
||||
try {
|
||||
handleEl.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
@@ -149,11 +186,54 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
endDrag(s.pointerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress the next click event on handle to prevent accidental click after drag.
|
||||
*/
|
||||
function suppressClickOnce(): void {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
blockEvent(e);
|
||||
};
|
||||
handleEl.addEventListener('click', onClick, { capture: true, once: true });
|
||||
// Safety cleanup if no click fires (extended timeout for touch devices)
|
||||
window.setTimeout(() => {
|
||||
handleEl.removeEventListener('click', onClick, { capture: true });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate drag mode (when using delayed activation).
|
||||
*/
|
||||
function activateDrag(pointerId: number): void {
|
||||
const s = session;
|
||||
if (!s || s.pointerId !== pointerId || s.activated) return;
|
||||
|
||||
s.activated = true;
|
||||
handleEl.dataset.dragging = 'true';
|
||||
clearActivationTimer();
|
||||
|
||||
try {
|
||||
handleEl.setPointerCapture(pointerId);
|
||||
} catch {
|
||||
// Pointer capture may fail on some elements/browsers
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowPointerMove(event: PointerEvent): void {
|
||||
const s = session;
|
||||
if (!s) return;
|
||||
if (event.pointerId !== s.pointerId) return;
|
||||
|
||||
// Check if drag needs activation (delayed mode)
|
||||
if (!s.activated) {
|
||||
if (!delayedActivation || moveThresholdSq <= 0) return;
|
||||
|
||||
const dx = event.clientX - s.startClientX;
|
||||
const dy = event.clientY - s.startClientY;
|
||||
if (dx * dx + dy * dy < moveThresholdSq) return;
|
||||
|
||||
activateDrag(event.pointerId);
|
||||
}
|
||||
|
||||
blockEvent(event);
|
||||
|
||||
applyNextPosition({
|
||||
@@ -167,7 +247,11 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
if (!s) return;
|
||||
if (event.pointerId !== s.pointerId) return;
|
||||
|
||||
blockEvent(event);
|
||||
// Only block event and suppress click if drag was activated
|
||||
if (s.activated) {
|
||||
blockEvent(event);
|
||||
suppressClickOnce();
|
||||
}
|
||||
endDrag(event.pointerId);
|
||||
}
|
||||
|
||||
@@ -176,30 +260,49 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
if (!s) return;
|
||||
if (event.pointerId !== s.pointerId) return;
|
||||
|
||||
blockEvent(event);
|
||||
cancelDrag();
|
||||
if (s.activated) {
|
||||
blockEvent(event);
|
||||
cancelDrag();
|
||||
} else {
|
||||
endDrag(event.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (!session) return;
|
||||
const s = session;
|
||||
if (!s) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
|
||||
cancelDrag();
|
||||
if (s.activated) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
cancelDrag();
|
||||
} else {
|
||||
endDrag(s.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowBlur(): void {
|
||||
if (!session) return;
|
||||
cancelDrag();
|
||||
const s = session;
|
||||
if (!s) return;
|
||||
|
||||
if (s.activated) {
|
||||
cancelDrag();
|
||||
} else {
|
||||
endDrag(s.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
if (!session) return;
|
||||
if (document.visibilityState === 'hidden') {
|
||||
const s = session;
|
||||
if (!s) return;
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
|
||||
if (s.activated) {
|
||||
cancelDrag();
|
||||
} else {
|
||||
endDrag(s.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +317,10 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
if (event.button !== 0) return;
|
||||
if (!event.isPrimary) return;
|
||||
|
||||
blockEvent(event);
|
||||
// Only block event immediately if not using delayed activation
|
||||
if (!delayedActivation) {
|
||||
blockEvent(event);
|
||||
}
|
||||
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
const startPosition = roundPosition({ left: rect.left, top: rect.top });
|
||||
@@ -226,9 +332,12 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
offsetY: event.clientY - rect.top,
|
||||
targetWidth: rect.width,
|
||||
targetHeight: rect.height,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
activated: !delayedActivation,
|
||||
};
|
||||
|
||||
handleEl.dataset.dragging = 'true';
|
||||
handleEl.dataset.dragging = session.activated ? 'true' : 'false';
|
||||
|
||||
try {
|
||||
handleEl.setPointerCapture(event.pointerId);
|
||||
@@ -236,6 +345,15 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
// Pointer capture may fail on some elements/browsers
|
||||
}
|
||||
|
||||
// Start activation timer for delayed mode
|
||||
if (delayedActivation) {
|
||||
clearActivationTimer();
|
||||
const pointerId = event.pointerId;
|
||||
activationTimer = window.setTimeout(() => {
|
||||
activateDrag(pointerId);
|
||||
}, clickThresholdMs);
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onWindowPointerMove, WINDOW_CAPTURE);
|
||||
window.addEventListener('pointerup', onWindowPointerUp, WINDOW_CAPTURE);
|
||||
window.addEventListener('pointercancel', onWindowPointerCancel, WINDOW_CAPTURE);
|
||||
@@ -256,13 +374,18 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void {
|
||||
// Best-effort teardown if a drag is active
|
||||
if (session) {
|
||||
try {
|
||||
cancelDrag();
|
||||
if (session.activated) {
|
||||
cancelDrag();
|
||||
} else {
|
||||
endDrag(session.pointerId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
teardownWindowListeners();
|
||||
clearActivationTimer();
|
||||
session = null;
|
||||
handleEl.dataset.dragging = 'false';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared SVG Icons for Web Editor UI
|
||||
*
|
||||
* All icons are created as inline SVG elements to:
|
||||
* - Avoid external asset dependencies
|
||||
* - Support theming via `currentColor`
|
||||
* - Enable direct DOM manipulation
|
||||
*
|
||||
* Design standards:
|
||||
* - ViewBox: 20x20
|
||||
* - Stroke width: 2px
|
||||
* - Line caps/joins: round
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Icon Factory Helpers
|
||||
// =============================================================================
|
||||
|
||||
function createSvgElement(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 20 20');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
return svg;
|
||||
}
|
||||
|
||||
function createStrokePath(d: string): SVGPathElement {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
return path;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Icon Creators
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Minus icon (—) for minimize button
|
||||
*/
|
||||
export function createMinusIcon(): SVGElement {
|
||||
const svg = createSvgElement();
|
||||
svg.append(createStrokePath('M5 10h10'));
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plus icon (+) for restore/expand button
|
||||
*/
|
||||
export function createPlusIcon(): SVGElement {
|
||||
const svg = createSvgElement();
|
||||
svg.append(createStrokePath('M10 5v10M5 10h10'));
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close icon (×) for close button
|
||||
*/
|
||||
export function createCloseIcon(): SVGElement {
|
||||
const svg = createSvgElement();
|
||||
svg.append(createStrokePath('M6 6l8 8M14 6l-8 8'));
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grip icon (6 dots) for drag handle
|
||||
*/
|
||||
export function createGripIcon(): SVGElement {
|
||||
const svg = createSvgElement();
|
||||
|
||||
const DOT_POSITIONS: ReadonlyArray<readonly [number, number]> = [
|
||||
[7, 6],
|
||||
[13, 6],
|
||||
[7, 10],
|
||||
[13, 10],
|
||||
[7, 14],
|
||||
[13, 14],
|
||||
];
|
||||
|
||||
for (const [cx, cy] of DOT_POSITIONS) {
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', String(cx));
|
||||
circle.setAttribute('cy', String(cy));
|
||||
circle.setAttribute('r', '1.4');
|
||||
circle.setAttribute('fill', 'currentColor');
|
||||
svg.append(circle);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chevron icon (▼) for collapse/expand indicator
|
||||
*/
|
||||
export function createChevronIcon(): SVGElement {
|
||||
const svg = createSvgElement();
|
||||
svg.classList.add('we-chevron');
|
||||
svg.append(createStrokePath('M7 8l3 3 3-3'));
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo icon (↶) for undo button
|
||||
*/
|
||||
export function createUndoIcon(): SVGElement {
|
||||
const svg = createSvgElement();
|
||||
// Arrow pointing left with curved tail
|
||||
svg.append(createStrokePath('M4 10h10a3 3 0 0 0 0-6H7M4 10l3-3M4 10l3 3'));
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo icon (↷) for redo button
|
||||
*/
|
||||
export function createRedoIcon(): SVGElement {
|
||||
const svg = createSvgElement();
|
||||
// Arrow pointing right with curved tail
|
||||
svg.append(createStrokePath('M16 10H6a3 3 0 0 1 0-6h7M16 10l-3-3M16 10l-3 3'));
|
||||
return svg;
|
||||
}
|
||||
+232
-33
@@ -19,6 +19,7 @@ import type { DesignTokensService } from '../../../core/design-tokens';
|
||||
import { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group';
|
||||
import { createInputContainer, type InputContainer } from '../components/input-container';
|
||||
import { createColorField, type ColorField } from './color-field';
|
||||
import { createGradientControl } from './gradient-control';
|
||||
import { combineLengthValue, formatLengthForDisplay } from './css-helpers';
|
||||
import { wireNumberStepping } from './number-stepping';
|
||||
import type { DesignControl } from '../types';
|
||||
@@ -31,6 +32,10 @@ const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
const BORDER_STYLE_VALUES = ['solid', 'dashed', 'dotted', 'none'] as const;
|
||||
|
||||
/** Color type for border: solid uses border-color, gradient uses border-image-source */
|
||||
const BORDER_COLOR_TYPE_VALUES = ['solid', 'gradient'] as const;
|
||||
type BorderColorType = (typeof BORDER_COLOR_TYPE_VALUES)[number];
|
||||
|
||||
const BORDER_EDGE_VALUES = ['all', 'top', 'right', 'bottom', 'left'] as const;
|
||||
type BorderEdge = (typeof BORDER_EDGE_VALUES)[number];
|
||||
|
||||
@@ -109,14 +114,6 @@ function isFieldFocused(el: HTMLElement): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLength(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
if (/^-?(?:\d+|\d*\.\d+)$/.test(trimmed)) return `${trimmed}px`;
|
||||
if (/^-?\d+\.$/.test(trimmed)) return `${trimmed.slice(0, -1)}px`;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function readInlineValue(element: Element, property: string): string {
|
||||
try {
|
||||
const style = (element as HTMLElement).style;
|
||||
@@ -134,6 +131,17 @@ function readComputedValue(element: Element, property: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer border color type from border-image-source value.
|
||||
* Returns 'gradient' if a gradient is detected, 'solid' otherwise.
|
||||
*/
|
||||
function inferBorderColorType(borderImageSource: string): BorderColorType {
|
||||
const trimmed = borderImageSource.trim().toLowerCase();
|
||||
if (!trimmed || trimmed === 'none') return 'solid';
|
||||
if (/\b(?:linear|radial|conic)-gradient\s*\(/i.test(trimmed)) return 'gradient';
|
||||
return 'solid';
|
||||
}
|
||||
|
||||
function createBorderEdgeIcon(edge: BorderEdge): SVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 15 15');
|
||||
@@ -244,6 +252,7 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
|
||||
let currentTarget: Element | null = null;
|
||||
let currentBorderEdge: BorderEdge = 'all';
|
||||
let currentColorType: BorderColorType = 'solid';
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'we-field-group';
|
||||
@@ -252,24 +261,6 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
// DOM Helpers
|
||||
// ===========================================================================
|
||||
|
||||
function createInputRow(
|
||||
labelText: string,
|
||||
ariaLabel: string,
|
||||
): { row: HTMLDivElement; input: HTMLInputElement } {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'we-field';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'we-field-label';
|
||||
label.textContent = labelText;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'we-input';
|
||||
input.autocomplete = 'off';
|
||||
input.setAttribute('aria-label', ariaLabel);
|
||||
row.append(label, input);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function createSelectRow(
|
||||
labelText: string,
|
||||
ariaLabel: string,
|
||||
@@ -323,15 +314,41 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
borderEdgeMount.style.flex = '1';
|
||||
borderEdgeRow.append(borderEdgeLabel, borderEdgeMount);
|
||||
|
||||
const { row: borderWidthRow, input: borderWidthInput } = createInputRow('Width', 'Border Width');
|
||||
// Border Width with InputContainer
|
||||
const borderWidthRow = document.createElement('div');
|
||||
borderWidthRow.className = 'we-field';
|
||||
const borderWidthLabel = document.createElement('span');
|
||||
borderWidthLabel.className = 'we-field-label';
|
||||
borderWidthLabel.textContent = 'Width';
|
||||
const borderWidthContainer = createInputContainer({
|
||||
ariaLabel: 'Border Width',
|
||||
inputMode: 'decimal',
|
||||
prefix: null,
|
||||
suffix: 'px',
|
||||
});
|
||||
borderWidthRow.append(borderWidthLabel, borderWidthContainer.root);
|
||||
const borderWidthInput = borderWidthContainer.input;
|
||||
|
||||
const { row: borderStyleRow, select: borderStyleSelect } = createSelectRow(
|
||||
'Style',
|
||||
'Border Style',
|
||||
BORDER_STYLE_VALUES,
|
||||
);
|
||||
|
||||
// Color Type selector (solid/gradient)
|
||||
const { row: colorTypeRow, select: colorTypeSelect } = createSelectRow(
|
||||
'Type',
|
||||
'Border Color Type',
|
||||
BORDER_COLOR_TYPE_VALUES,
|
||||
);
|
||||
|
||||
// Solid color row
|
||||
const { row: borderColorRow, colorFieldContainer: borderColorContainer } =
|
||||
createColorRow('Color');
|
||||
|
||||
// Gradient mount for border-image-source
|
||||
const borderGradientMount = document.createElement('div');
|
||||
|
||||
// Border Radius (unified + per-corner editing)
|
||||
const borderRadiusRow = document.createElement('div');
|
||||
borderRadiusRow.className = 'we-field';
|
||||
@@ -402,7 +419,8 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
borderRadiusCorners['bottom-right'].root,
|
||||
);
|
||||
|
||||
borderRadiusControl.append(borderRadiusUnifiedRow, borderRadiusCornersGrid);
|
||||
// Keep corners grid separate from the unified row for full-width display when expanded
|
||||
borderRadiusControl.append(borderRadiusUnifiedRow);
|
||||
borderRadiusRow.append(borderRadiusLabel, borderRadiusControl);
|
||||
|
||||
const borderRadiusField: BorderRadiusFieldState = {
|
||||
@@ -419,13 +437,31 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
cornersMaterialized: false,
|
||||
};
|
||||
|
||||
// Create combined row for Width and Radius
|
||||
const widthAndRadiusRow = document.createElement('div');
|
||||
widthAndRadiusRow.className = 'we-field-row';
|
||||
borderWidthRow.style.flex = '1';
|
||||
borderWidthRow.style.minWidth = '0';
|
||||
borderRadiusRow.style.flex = '1';
|
||||
borderRadiusRow.style.minWidth = '0';
|
||||
widthAndRadiusRow.append(borderWidthRow, borderRadiusRow);
|
||||
|
||||
wireNumberStepping(disposer, borderWidthInput, { mode: 'css-length' });
|
||||
wireNumberStepping(disposer, borderRadiusUnified.input, { mode: 'css-length' });
|
||||
for (const corner of BORDER_RADIUS_CORNERS) {
|
||||
wireNumberStepping(disposer, borderRadiusCorners[corner].input, { mode: 'css-length' });
|
||||
}
|
||||
|
||||
root.append(borderEdgeRow, borderWidthRow, borderStyleRow, borderColorRow, borderRadiusRow);
|
||||
// borderRadiusCornersGrid placed after widthAndRadiusRow to span full width when expanded
|
||||
root.append(
|
||||
borderEdgeRow,
|
||||
widthAndRadiusRow,
|
||||
borderRadiusCornersGrid,
|
||||
borderStyleRow,
|
||||
colorTypeRow,
|
||||
borderColorRow,
|
||||
borderGradientMount,
|
||||
);
|
||||
container.appendChild(root);
|
||||
disposer.add(() => root.remove());
|
||||
|
||||
@@ -479,6 +515,19 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
});
|
||||
disposer.add(() => borderColorField.dispose());
|
||||
|
||||
// ===========================================================================
|
||||
// Gradient Control (for border-image-source)
|
||||
// ===========================================================================
|
||||
|
||||
const borderGradientControl = createGradientControl({
|
||||
container: borderGradientMount,
|
||||
transactionManager,
|
||||
tokensService,
|
||||
property: 'border-image-source',
|
||||
allowNone: true,
|
||||
});
|
||||
disposer.add(() => borderGradientControl.dispose());
|
||||
|
||||
// ===========================================================================
|
||||
// Field State Map
|
||||
// ===========================================================================
|
||||
@@ -607,6 +656,101 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
commitBorderRadiusTransaction();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Color Type (Solid / Gradient)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Update visibility of color-related rows based on currentColorType.
|
||||
*/
|
||||
function updateColorTypeVisibility(): void {
|
||||
borderColorRow.hidden = currentColorType !== 'solid';
|
||||
borderGradientMount.hidden = currentColorType !== 'gradient';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update edge selector disabled state based on color type.
|
||||
* Gradient mode requires 'all' edges (border-image doesn't support per-edge).
|
||||
*/
|
||||
function updateEdgeSelectorState(): void {
|
||||
const hasTarget = Boolean(currentTarget && currentTarget.isConnected);
|
||||
|
||||
// In gradient mode, lock edge to 'all' since border-image applies to all edges
|
||||
if (currentColorType === 'gradient') {
|
||||
if (currentBorderEdge !== 'all') {
|
||||
commitTransaction('border-width');
|
||||
commitTransaction('border-style');
|
||||
commitTransaction('border-color');
|
||||
currentBorderEdge = 'all';
|
||||
}
|
||||
borderEdgeGroup.setValue('all');
|
||||
}
|
||||
|
||||
borderEdgeGroup.setDisabled(!hasTarget || currentColorType === 'gradient');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set border color type and apply necessary CSS changes.
|
||||
* Uses multiStyle transaction to atomically set border-image properties.
|
||||
*/
|
||||
function setColorType(type: BorderColorType): void {
|
||||
const target = currentTarget;
|
||||
|
||||
currentColorType = type;
|
||||
colorTypeSelect.value = type;
|
||||
|
||||
updateColorTypeVisibility();
|
||||
updateEdgeSelectorState();
|
||||
|
||||
if (!target || !target.isConnected) return;
|
||||
|
||||
// Use multiStyle to atomically manage border-image properties
|
||||
const handle = transactionManager.beginMultiStyle(target, [
|
||||
'border-image-source',
|
||||
'border-image-slice',
|
||||
]);
|
||||
if (!handle) return;
|
||||
|
||||
if (type === 'solid') {
|
||||
// Clear border-image when switching to solid color
|
||||
handle.set({
|
||||
'border-image-source': 'none',
|
||||
'border-image-slice': '',
|
||||
});
|
||||
} else {
|
||||
// Set up border-image for gradient mode
|
||||
const inlineSource = readInlineValue(target, 'border-image-source');
|
||||
const computedSource = readComputedValue(target, 'border-image-source');
|
||||
const currentSource = inlineSource || computedSource;
|
||||
|
||||
// Use existing gradient or provide a default
|
||||
const hasValidGradient =
|
||||
currentSource &&
|
||||
currentSource.trim() &&
|
||||
currentSource.trim().toLowerCase() !== 'none' &&
|
||||
/\b(?:linear|radial|conic)-gradient\s*\(/i.test(currentSource);
|
||||
|
||||
const gradientValue = hasValidGradient
|
||||
? currentSource
|
||||
: 'linear-gradient(90deg, #000000, #ffffff)';
|
||||
|
||||
handle.set({
|
||||
'border-image-source': gradientValue,
|
||||
'border-image-slice': '1',
|
||||
});
|
||||
}
|
||||
|
||||
handle.commit({ merge: true });
|
||||
}
|
||||
|
||||
// Wire color type selector change event
|
||||
disposer.listen(colorTypeSelect, 'change', () => {
|
||||
const type = colorTypeSelect.value as BorderColorType;
|
||||
setColorType(type);
|
||||
borderGradientControl.refresh();
|
||||
syncAllFields();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Field Synchronization
|
||||
// ===========================================================================
|
||||
@@ -682,6 +826,7 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
input.disabled = true;
|
||||
input.value = '';
|
||||
input.placeholder = '';
|
||||
if (property === 'border-width') borderWidthContainer.setSuffix('px');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -692,7 +837,15 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
|
||||
const inlineValue = readInlineValue(target, cssProperty);
|
||||
const computedValue = readComputedValue(target, cssProperty);
|
||||
input.value = inlineValue || computedValue;
|
||||
|
||||
// Use formatLengthForDisplay for border-width to set proper suffix
|
||||
if (property === 'border-width') {
|
||||
const formatted = formatLengthForDisplay(inlineValue || computedValue);
|
||||
input.value = formatted.value;
|
||||
borderWidthContainer.setSuffix(formatted.suffix);
|
||||
} else {
|
||||
input.value = inlineValue || computedValue;
|
||||
}
|
||||
input.placeholder = '';
|
||||
} else if (field.kind === 'select') {
|
||||
const select = field.element;
|
||||
@@ -742,7 +895,9 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
function syncAllFields(): void {
|
||||
for (const p of PROPS) syncField(p);
|
||||
const hasTarget = Boolean(currentTarget && currentTarget.isConnected);
|
||||
borderEdgeGroup.setDisabled(!hasTarget);
|
||||
colorTypeSelect.disabled = !hasTarget;
|
||||
updateColorTypeVisibility();
|
||||
updateEdgeSelectorState();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -754,11 +909,16 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
if (field.kind !== 'text') return;
|
||||
|
||||
const input = field.element;
|
||||
const normalize = property === 'border-width' ? normalizeLength : (v: string) => v.trim();
|
||||
|
||||
// Use combineLengthValue for border-width to include suffix
|
||||
const getNextValue =
|
||||
property === 'border-width'
|
||||
? () => combineLengthValue(input.value, borderWidthContainer.getSuffixText())
|
||||
: () => input.value.trim();
|
||||
|
||||
disposer.listen(input, 'input', () => {
|
||||
const handle = beginTransaction(property);
|
||||
if (handle) handle.set(normalize(input.value));
|
||||
if (handle) handle.set(getNextValue());
|
||||
});
|
||||
|
||||
disposer.listen(input, 'blur', () => {
|
||||
@@ -932,11 +1092,50 @@ export function createBorderControl(options: BorderControlOptions): DesignContro
|
||||
if (disposer.isDisposed) return;
|
||||
if (element !== currentTarget) commitAllTransactions();
|
||||
currentTarget = element;
|
||||
|
||||
// Infer color type from border-image-source
|
||||
if (element && element.isConnected) {
|
||||
const borderImageSource =
|
||||
readInlineValue(element, 'border-image-source') ||
|
||||
readComputedValue(element, 'border-image-source');
|
||||
currentColorType = inferBorderColorType(borderImageSource);
|
||||
} else {
|
||||
currentColorType = 'solid';
|
||||
}
|
||||
colorTypeSelect.value = currentColorType;
|
||||
|
||||
// In gradient mode, ensure edge is set to 'all'
|
||||
if (currentColorType === 'gradient') {
|
||||
currentBorderEdge = 'all';
|
||||
borderEdgeGroup.setValue('all');
|
||||
}
|
||||
|
||||
// Update gradient control target
|
||||
borderGradientControl.setTarget(element);
|
||||
syncAllFields();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (disposer.isDisposed) return;
|
||||
|
||||
// Re-infer color type from element to handle external changes (CSS panel, Undo/Redo)
|
||||
const target = currentTarget;
|
||||
if (target && target.isConnected) {
|
||||
const borderImageSource =
|
||||
readInlineValue(target, 'border-image-source') ||
|
||||
readComputedValue(target, 'border-image-source');
|
||||
const inferredType = inferBorderColorType(borderImageSource);
|
||||
if (inferredType !== currentColorType) {
|
||||
currentColorType = inferredType;
|
||||
colorTypeSelect.value = inferredType;
|
||||
if (inferredType === 'gradient') {
|
||||
currentBorderEdge = 'all';
|
||||
borderEdgeGroup.setValue('all');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
borderGradientControl.refresh();
|
||||
syncAllFields();
|
||||
}
|
||||
|
||||
|
||||
+1412
-8
File diff suppressed because it is too large
Load Diff
+409
-234
@@ -77,6 +77,19 @@ interface ThumbDragSession {
|
||||
thumbElement: HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard session state for thumb stepping (Arrow keys).
|
||||
* Maintains a snapshot for Escape rollback and keeps thumbs stable during stepping.
|
||||
*/
|
||||
interface ThumbKeyboardSession {
|
||||
/** ID of the stop being adjusted */
|
||||
stopId: StopId;
|
||||
/** Position snapshot before stepping started (for rollback on Escape) */
|
||||
initialPositions: Map<StopId, number>;
|
||||
/** The thumb element being adjusted (focus anchor) */
|
||||
thumbElement: HTMLElement;
|
||||
}
|
||||
|
||||
/** Basic gradient stop (used in parsing and UI state) */
|
||||
interface GradientStop {
|
||||
color: string;
|
||||
@@ -986,14 +999,33 @@ export interface GradientControlOptions {
|
||||
transactionManager: TransactionManager;
|
||||
/** Optional: Design tokens service for TokenPill/TokenPicker integration (Phase 5.3) */
|
||||
tokensService?: DesignTokensService;
|
||||
/**
|
||||
* CSS property to write the gradient value to.
|
||||
* Defaults to 'background-image'.
|
||||
* Use 'border-image-source' for border gradient support.
|
||||
*/
|
||||
property?: string;
|
||||
/**
|
||||
* Whether to show the 'None' option in the gradient type selector.
|
||||
* Defaults to true.
|
||||
* Set to false for text gradient mode where 'none' would make text invisible.
|
||||
*/
|
||||
allowNone?: boolean;
|
||||
}
|
||||
|
||||
export function createGradientControl(options: GradientControlOptions): DesignControl {
|
||||
const { container, transactionManager, tokensService } = options;
|
||||
const {
|
||||
container,
|
||||
transactionManager,
|
||||
tokensService,
|
||||
property: cssProperty = 'background-image',
|
||||
allowNone = true,
|
||||
} = options;
|
||||
const disposer = new Disposer();
|
||||
|
||||
let currentTarget: Element | null = null;
|
||||
let currentType: GradientType = 'none';
|
||||
// Default type is 'linear' when allowNone is false, otherwise 'none'
|
||||
let currentType: GradientType = allowNone ? 'none' : 'linear';
|
||||
|
||||
// Current stops array - supports N stops with stable identity
|
||||
let currentStops: StopModel[] = createDefaultStopModels();
|
||||
@@ -1002,9 +1034,8 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
// Active thumb drag session (null when not dragging)
|
||||
let thumbDrag: ThumbDragSession | null = null;
|
||||
|
||||
// Legacy values for backward compatibility with current 2-stop UI
|
||||
let stop1ColorValue = DEFAULT_STOP_1.color;
|
||||
let stop2ColorValue = DEFAULT_STOP_2.color;
|
||||
// Active thumb keyboard session (null when not stepping via arrow keys)
|
||||
let thumbKeyboard: ThumbKeyboardSession | null = null;
|
||||
|
||||
let backgroundHandle: StyleTransactionHandle | null = null;
|
||||
|
||||
@@ -1066,50 +1097,19 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
return { row, select };
|
||||
}
|
||||
|
||||
function createStopRow(
|
||||
labelText: string,
|
||||
posAriaLabel: string,
|
||||
): {
|
||||
row: HTMLDivElement;
|
||||
colorFieldContainer: HTMLDivElement;
|
||||
posInput: HTMLInputElement;
|
||||
} {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'we-field';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'we-field-label';
|
||||
label.textContent = labelText;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'display:flex;flex:1;min-width:0;gap:4px;';
|
||||
|
||||
const colorFieldContainer = document.createElement('div');
|
||||
colorFieldContainer.style.cssText = 'flex:1;min-width:0;';
|
||||
|
||||
const posInput = document.createElement('input');
|
||||
posInput.type = 'text';
|
||||
posInput.className = 'we-input';
|
||||
posInput.autocomplete = 'off';
|
||||
posInput.spellcheck = false;
|
||||
posInput.inputMode = 'decimal';
|
||||
posInput.setAttribute('aria-label', posAriaLabel);
|
||||
posInput.style.cssText = 'flex:0 0 56px;text-align:right;';
|
||||
posInput.placeholder = '0';
|
||||
|
||||
wrapper.append(colorFieldContainer, posInput);
|
||||
row.append(label, wrapper);
|
||||
return { row, colorFieldContainer, posInput };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Create UI Elements
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Build gradient type options based on allowNone parameter
|
||||
const gradientTypeOptions = allowNone
|
||||
? GRADIENT_TYPES
|
||||
: GRADIENT_TYPES.filter((t) => t.value !== 'none');
|
||||
|
||||
const { row: typeRow, select: typeSelect } = createSelectRow(
|
||||
'Type',
|
||||
'Gradient Type',
|
||||
GRADIENT_TYPES,
|
||||
gradientTypeOptions,
|
||||
);
|
||||
|
||||
// Gradient preview bar
|
||||
@@ -1160,18 +1160,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
stopsList.className = 'we-gradient-stops-list';
|
||||
stopsList.setAttribute('role', 'list');
|
||||
|
||||
const {
|
||||
row: stop1Row,
|
||||
colorFieldContainer: stop1ColorContainer,
|
||||
posInput: stop1PosInput,
|
||||
} = createStopRow('Stop 1', 'Stop 1 Position (%)');
|
||||
|
||||
const {
|
||||
row: stop2Row,
|
||||
colorFieldContainer: stop2ColorContainer,
|
||||
posInput: stop2PosInput,
|
||||
} = createStopRow('Stop 2', 'Stop 2 Position (%)');
|
||||
|
||||
root.append(
|
||||
typeRow,
|
||||
gradientBarRow,
|
||||
@@ -1181,8 +1169,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
posYRow,
|
||||
stopsHeaderRow,
|
||||
stopsList,
|
||||
stop1Row,
|
||||
stop2Row,
|
||||
);
|
||||
container.append(root);
|
||||
disposer.add(() => root.remove());
|
||||
@@ -1212,63 +1198,84 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
shiftStep: 10,
|
||||
altStep: 0.1,
|
||||
});
|
||||
wireNumberStepping(disposer, stop1PosInput, {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single Position Input bound to selectedStopId (Phase 7)
|
||||
// Host is re-parented into the selected row's position editor slot.
|
||||
// ---------------------------------------------------------------------------
|
||||
const selectedStopPosHost = document.createElement('div');
|
||||
|
||||
const selectedStopPosInput = document.createElement('input');
|
||||
selectedStopPosInput.type = 'text';
|
||||
selectedStopPosInput.className = 'we-gradient-stop-pos-input';
|
||||
selectedStopPosInput.autocomplete = 'off';
|
||||
selectedStopPosInput.spellcheck = false;
|
||||
selectedStopPosInput.inputMode = 'decimal';
|
||||
selectedStopPosInput.placeholder = '0';
|
||||
selectedStopPosInput.setAttribute('aria-label', 'Selected Stop Position (%)');
|
||||
selectedStopPosHost.append(selectedStopPosInput);
|
||||
|
||||
// Enable keyboard stepping (↑/↓ to increment/decrement)
|
||||
wireNumberStepping(disposer, selectedStopPosInput, {
|
||||
mode: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
shiftStep: 10,
|
||||
altStep: 0.1,
|
||||
});
|
||||
wireNumberStepping(disposer, stop2PosInput, {
|
||||
mode: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
shiftStep: 10,
|
||||
altStep: 0.1,
|
||||
});
|
||||
|
||||
// Create color fields
|
||||
const stop1ColorField: ColorField = createColorField({
|
||||
container: stop1ColorContainer,
|
||||
ariaLabel: 'Stop 1 Color',
|
||||
tokensService,
|
||||
getTokenTarget: () => currentTarget,
|
||||
onInput: (value) => {
|
||||
stop1ColorValue = value;
|
||||
previewGradient();
|
||||
},
|
||||
onCommit: () => {
|
||||
commitTransaction();
|
||||
syncAllFields();
|
||||
},
|
||||
onCancel: () => {
|
||||
rollbackTransaction();
|
||||
syncAllFields(true);
|
||||
},
|
||||
});
|
||||
disposer.add(() => stop1ColorField.dispose());
|
||||
/**
|
||||
* Commit the position edit: sort stops and finalize the transaction.
|
||||
* Called on blur or Enter key.
|
||||
*/
|
||||
function commitSelectedStopPosition(): void {
|
||||
// Commit-time sort ensures CSS output is monotonically ordered
|
||||
sortCurrentStopsByPosition();
|
||||
|
||||
const stop2ColorField: ColorField = createColorField({
|
||||
container: stop2ColorContainer,
|
||||
ariaLabel: 'Stop 2 Color',
|
||||
tokensService,
|
||||
getTokenTarget: () => currentTarget,
|
||||
onInput: (value) => {
|
||||
stop2ColorValue = value;
|
||||
// Only commit if we have an active transaction
|
||||
if (backgroundHandle) {
|
||||
previewGradient();
|
||||
},
|
||||
onCommit: () => {
|
||||
commitTransaction();
|
||||
syncAllFields();
|
||||
},
|
||||
onCancel: () => {
|
||||
rollbackTransaction();
|
||||
syncAllFields(true);
|
||||
},
|
||||
}
|
||||
syncAllFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the position edit and rollback to the original value.
|
||||
* Called on Escape key.
|
||||
*/
|
||||
function cancelSelectedStopPosition(): void {
|
||||
rollbackTransaction();
|
||||
syncAllFields(true);
|
||||
}
|
||||
|
||||
// Handle input changes - update model and preview in real-time
|
||||
disposer.listen(selectedStopPosInput, 'input', () => {
|
||||
const id = selectedStopId;
|
||||
if (!id) return;
|
||||
|
||||
const parsed = parseNumber(selectedStopPosInput.value);
|
||||
if (parsed === null) return;
|
||||
|
||||
// Update model and preview in real-time
|
||||
setStopPositionById(id, parsed);
|
||||
previewGradient();
|
||||
});
|
||||
|
||||
// Commit on blur
|
||||
disposer.listen(selectedStopPosInput, 'blur', commitSelectedStopPosition);
|
||||
|
||||
// Handle Enter/Escape keys
|
||||
disposer.listen(selectedStopPosInput, 'keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitSelectedStopPosition();
|
||||
selectedStopPosInput.blur();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelSelectedStopPosition();
|
||||
}
|
||||
});
|
||||
disposer.add(() => stop2ColorField.dispose());
|
||||
|
||||
// Single ColorField bound to selectedStopId (Phase 4E)
|
||||
// Host is re-parented into the selected row's editor slot.
|
||||
@@ -1286,17 +1293,9 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
const index = currentStops.findIndex((s) => s.id === id);
|
||||
if (index < 0) return;
|
||||
|
||||
// Update model (new path)
|
||||
// Update model
|
||||
currentStops[index]!.color = value;
|
||||
|
||||
// Sync legacy values (old path - Phase 4H will remove)
|
||||
if (index === 0) stop1ColorValue = value;
|
||||
if (index === 1) stop2ColorValue = value;
|
||||
|
||||
// Keep hidden legacy fields consistent
|
||||
if (index === 0) stop1ColorField.setValue(value);
|
||||
if (index === 1) stop2ColorField.setValue(value);
|
||||
|
||||
// Update placeholder when switching away from var()
|
||||
selectedStopColorField.setPlaceholder(
|
||||
needsColorPlaceholder(value) ? (currentStops[index]!.placeholderColor ?? '') : '',
|
||||
@@ -1327,7 +1326,7 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
|
||||
if (backgroundHandle) return backgroundHandle;
|
||||
|
||||
backgroundHandle = transactionManager.beginStyle(target, 'background-image');
|
||||
backgroundHandle = transactionManager.beginStyle(target, cssProperty);
|
||||
return backgroundHandle;
|
||||
}
|
||||
|
||||
@@ -1361,10 +1360,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
|
||||
const clamped = clampPercent(position);
|
||||
currentStops[index]!.position = clamped;
|
||||
|
||||
// Sync legacy position inputs for backward compatibility (Phase 4H will remove)
|
||||
if (index === 0) stop1PosInput.value = String(Math.round(clamped));
|
||||
if (index === 1) stop2PosInput.value = String(Math.round(clamped));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1378,12 +1373,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
stop.position = savedPos;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync legacy position inputs
|
||||
const stop1 = currentStops[0];
|
||||
const stop2 = currentStops[1];
|
||||
if (stop1) stop1PosInput.value = String(Math.round(stop1.position));
|
||||
if (stop2) stop2PosInput.value = String(Math.round(stop2.position));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1400,6 +1389,7 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
|
||||
// Remove dragging visual state
|
||||
gradientBar.classList.remove('we-gradient-bar--dragging');
|
||||
session.thumbElement.classList.remove('we-gradient-thumb--dragging');
|
||||
|
||||
// Best-effort: release capture (e.g., Escape cancel while pointer is still down)
|
||||
try {
|
||||
@@ -1409,6 +1399,11 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
}
|
||||
|
||||
if (commit) {
|
||||
// Commit-time sort ensures CSS output is monotonically ordered
|
||||
sortCurrentStopsByPosition();
|
||||
|
||||
// Update preview with sorted positions before committing
|
||||
previewGradient();
|
||||
commitTransaction();
|
||||
syncAllFields();
|
||||
} else {
|
||||
@@ -1432,6 +1427,163 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
return clampPercent(rawPercent);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Thumb Keyboard Stepping (Phase 9)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start a keyboard stepping session for a thumb.
|
||||
* Similar to drag session but triggered by arrow keys.
|
||||
*/
|
||||
function startThumbKeyboardSession(stopId: StopId, thumbElement: HTMLElement): void {
|
||||
if (thumbDrag) return;
|
||||
if (currentType === 'none') return;
|
||||
if (typeSelect.disabled) return;
|
||||
|
||||
// If session already exists for this stop, don't restart
|
||||
if (thumbKeyboard?.stopId === stopId) return;
|
||||
|
||||
// If switching stops, commit previous session first
|
||||
if (thumbKeyboard) {
|
||||
endThumbKeyboard(true);
|
||||
}
|
||||
|
||||
// Snapshot all positions for potential rollback
|
||||
const initialPositions = new Map<StopId, number>();
|
||||
for (const stop of currentStops) {
|
||||
initialPositions.set(stop.id, stop.position);
|
||||
}
|
||||
|
||||
thumbKeyboard = { stopId, initialPositions, thumbElement };
|
||||
beginTransaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* End the keyboard stepping session.
|
||||
* @param commit - If true, commit changes; if false, rollback to initial state
|
||||
*/
|
||||
function endThumbKeyboard(commit: boolean): void {
|
||||
const session = thumbKeyboard;
|
||||
if (!session) return;
|
||||
thumbKeyboard = null;
|
||||
|
||||
if (commit) {
|
||||
// Commit-time sort keeps CSS output monotonic
|
||||
sortCurrentStopsByPosition();
|
||||
previewGradient();
|
||||
commitTransaction();
|
||||
syncAllFields();
|
||||
} else {
|
||||
restoreStopPositions(session.initialPositions);
|
||||
rollbackTransaction();
|
||||
syncAllFields(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle focus on a thumb - select the corresponding stop.
|
||||
*/
|
||||
function handleThumbFocus(event: FocusEvent): void {
|
||||
if (thumbDrag) return;
|
||||
if (currentType === 'none') return;
|
||||
if (typeSelect.disabled) return;
|
||||
|
||||
const thumb = event.currentTarget as HTMLElement;
|
||||
const stopId = thumb.dataset.stopId;
|
||||
if (!stopId) return;
|
||||
|
||||
if (selectedStopId !== stopId) {
|
||||
selectedStopId = stopId;
|
||||
// Preserve thumbs to avoid focus loss during selection sync
|
||||
updateGradientBar({ preserveThumbs: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle blur on a thumb - commit any active keyboard session.
|
||||
*/
|
||||
function handleThumbBlur(event: FocusEvent): void {
|
||||
const session = thumbKeyboard;
|
||||
if (!session) return;
|
||||
|
||||
// Only commit if blur is from the session's thumb
|
||||
const thumb = event.currentTarget as HTMLElement;
|
||||
if (thumb !== session.thumbElement) return;
|
||||
|
||||
// Commit on blur (similar to input field behavior)
|
||||
endThumbKeyboard(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keydown on a thumb - arrow keys for stepping, Escape for cancel.
|
||||
*/
|
||||
function handleThumbKeyDown(event: KeyboardEvent): void {
|
||||
// Preserve navigation shortcuts (Cmd/Ctrl + Arrow for cursor movement)
|
||||
if (event.metaKey || event.ctrlKey) return;
|
||||
if (thumbDrag) return;
|
||||
if (currentType === 'none') return;
|
||||
if (typeSelect.disabled) return;
|
||||
|
||||
const thumb = event.currentTarget as HTMLElement;
|
||||
const stopId = thumb.dataset.stopId;
|
||||
if (!stopId) return;
|
||||
|
||||
// Escape cancels the keyboard session
|
||||
if (event.key === 'Escape') {
|
||||
const session = thumbKeyboard;
|
||||
if (!session || session.stopId !== stopId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
endThumbKeyboard(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrow keys for position adjustment
|
||||
const isArrow =
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown';
|
||||
if (!isArrow) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// ArrowLeft/ArrowDown: decrease, ArrowRight/ArrowUp: increase
|
||||
// Shift modifier: step by 10 instead of 1
|
||||
const sign = event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -1 : 1;
|
||||
const step = event.shiftKey ? 10 : 1;
|
||||
const delta = sign * step;
|
||||
|
||||
// Ensure stop is selected and session is active
|
||||
selectedStopId = stopId;
|
||||
startThumbKeyboardSession(stopId, thumb);
|
||||
|
||||
const idx = currentStops.findIndex((s) => s.id === stopId);
|
||||
if (idx < 0) return;
|
||||
|
||||
setStopPositionById(stopId, currentStops[idx]!.position + delta);
|
||||
previewGradient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync slider ARIA attributes on a thumb element.
|
||||
* Provides accessible name and value for screen readers.
|
||||
*/
|
||||
function syncThumbSliderAria(thumb: HTMLElement, position: number): void {
|
||||
const clamped = clampPercent(position);
|
||||
const rounded = Math.round(clamped * 100) / 100;
|
||||
const value = Object.is(rounded, -0) ? 0 : rounded;
|
||||
|
||||
thumb.setAttribute('role', 'slider');
|
||||
thumb.setAttribute('aria-label', 'Gradient stop position');
|
||||
thumb.setAttribute('aria-valuemin', '0');
|
||||
thumb.setAttribute('aria-valuemax', '100');
|
||||
thumb.setAttribute('aria-valuenow', String(value));
|
||||
thumb.setAttribute('aria-valuetext', `${value}%`);
|
||||
thumb.setAttribute('aria-orientation', 'horizontal');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stop Add/Delete (Phase 6)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1528,32 +1680,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
return rgbaToCss(interpolateRgba(leftRgba, rightRgba, t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync legacy stop fields (stop1/stop2) from currentStops model.
|
||||
* Required for backward compatibility until Phase 8 cleanup.
|
||||
*/
|
||||
function syncLegacyStopFieldsFromModels(): void {
|
||||
const stop1 = currentStops[0];
|
||||
const stop2 = currentStops[1];
|
||||
if (!stop1 || !stop2) return;
|
||||
|
||||
stop1ColorValue = stop1.color;
|
||||
stop2ColorValue = stop2.color;
|
||||
|
||||
stop1ColorField.setValue(stop1.color);
|
||||
stop2ColorField.setValue(stop2.color);
|
||||
|
||||
stop1ColorField.setPlaceholder(
|
||||
needsColorPlaceholder(stop1.color) ? (stop1.placeholderColor ?? '') : '',
|
||||
);
|
||||
stop2ColorField.setPlaceholder(
|
||||
needsColorPlaceholder(stop2.color) ? (stop2.placeholderColor ?? '') : '',
|
||||
);
|
||||
|
||||
stop1PosInput.value = String(Math.round(clampPercent(stop1.position)));
|
||||
stop2PosInput.value = String(Math.round(clampPercent(stop2.position)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a suggested position for adding a new stop.
|
||||
* Returns the midpoint between the selected stop and its next neighbor.
|
||||
@@ -1630,7 +1756,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
selectedStopId = newStop.id;
|
||||
sortCurrentStopsByPosition();
|
||||
|
||||
syncLegacyStopFieldsFromModels();
|
||||
previewGradient();
|
||||
commitTransaction();
|
||||
|
||||
@@ -1669,7 +1794,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
}
|
||||
|
||||
sortCurrentStopsByPosition();
|
||||
syncLegacyStopFieldsFromModels();
|
||||
previewGradient();
|
||||
commitTransaction();
|
||||
}
|
||||
@@ -1758,7 +1882,7 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
// Update position and color
|
||||
thumb.style.left = `${clampPercent(stop.position)}%`;
|
||||
thumb.style.backgroundColor = preview.color;
|
||||
thumb.setAttribute('aria-label', `Select stop at ${Math.round(stop.position)}%`);
|
||||
syncThumbSliderAria(thumb, stop.position);
|
||||
|
||||
// Update active state
|
||||
const isActive = stopId === selectedStopId;
|
||||
@@ -1783,11 +1907,16 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
thumb.dataset.stopId = model.id;
|
||||
thumb.style.left = `${clampPercent(stop.position)}%`;
|
||||
thumb.style.backgroundColor = preview.color;
|
||||
thumb.setAttribute('aria-label', `Select stop at ${Math.round(stop.position)}%`);
|
||||
syncThumbSliderAria(thumb, stop.position);
|
||||
|
||||
// Pointer event handlers for drag (Phase 5)
|
||||
thumb.addEventListener('pointerdown', handleThumbPointerDown);
|
||||
|
||||
// Keyboard and focus handlers (Phase 9)
|
||||
thumb.addEventListener('keydown', handleThumbKeyDown);
|
||||
thumb.addEventListener('focus', handleThumbFocus);
|
||||
thumb.addEventListener('blur', handleThumbBlur);
|
||||
|
||||
gradientThumbs.append(thumb);
|
||||
}
|
||||
}
|
||||
@@ -1810,6 +1939,10 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
// Prevent re-entry if drag is already in progress
|
||||
if (thumbDrag) return;
|
||||
|
||||
// Defensive: don't allow drag when disabled or none type
|
||||
if (currentType === 'none') return;
|
||||
if (typeSelect.disabled) return;
|
||||
|
||||
// Only respond to primary button (left click) and primary pointer
|
||||
if (event.button !== 0) return;
|
||||
if (!event.isPrimary) return;
|
||||
@@ -1818,6 +1951,12 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
const stopId = thumb.dataset.stopId;
|
||||
if (!stopId) return;
|
||||
|
||||
// If a keyboard stepping session is active, transition to drag
|
||||
// (share the same transaction handle)
|
||||
if (thumbKeyboard) {
|
||||
thumbKeyboard = null;
|
||||
}
|
||||
|
||||
// Prevent default to avoid text selection, button activation, etc.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1839,8 +1978,9 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
thumbElement: thumb,
|
||||
};
|
||||
|
||||
// Add visual feedback
|
||||
// Add visual feedback - dragging thumb raised above others
|
||||
gradientBar.classList.add('we-gradient-bar--dragging');
|
||||
thumb.classList.add('we-gradient-thumb--dragging');
|
||||
|
||||
// Capture pointer for reliable tracking outside element bounds
|
||||
try {
|
||||
@@ -1934,13 +2074,17 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
if (currentType === 'none') return;
|
||||
if (models.length === 0 || stops.length === 0) return;
|
||||
|
||||
const formatPercentLabel = (value: number): string => {
|
||||
/**
|
||||
* Format position value for display (e.g., "50%")
|
||||
*/
|
||||
const formatPercentValue = (value: number): number => {
|
||||
const clamped = clampPercent(value);
|
||||
const rounded = Math.round(clamped * 100) / 100;
|
||||
const normalized = Object.is(rounded, -0) ? 0 : rounded;
|
||||
return `${normalized}%`;
|
||||
return Object.is(rounded, -0) ? 0 : rounded;
|
||||
};
|
||||
|
||||
const formatPercentLabel = (value: number): string => `${formatPercentValue(value)}%`;
|
||||
|
||||
// Build rows with original index for stable ordering
|
||||
const rows = stops
|
||||
.map((stop, index) => ({
|
||||
@@ -1952,7 +2096,8 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
.filter((r) => Boolean(r.model && r.preview))
|
||||
.sort((a, b) => a.stop.position - b.stop.position || a.index - b.index);
|
||||
|
||||
for (const r of rows) {
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
||||
const r = rows[rowIndex]!;
|
||||
const model = r.model!;
|
||||
const stop = r.stop;
|
||||
const preview = r.preview!;
|
||||
@@ -1967,10 +2112,28 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('aria-label', `Select stop at ${formatPercentLabel(stop.position)}`);
|
||||
|
||||
// Position column
|
||||
const pos = document.createElement('span');
|
||||
// Position column (Phase 7: static + editor dual-mode)
|
||||
const pos = document.createElement('div');
|
||||
pos.className = 'we-gradient-stop-pos';
|
||||
pos.textContent = formatPercentLabel(stop.position);
|
||||
|
||||
// Static display (shown when not selected)
|
||||
const posStatic = document.createElement('span');
|
||||
posStatic.className = 'we-gradient-stop-pos-static';
|
||||
posStatic.textContent = formatPercentLabel(stop.position);
|
||||
|
||||
// Position editor slot (shown when selected)
|
||||
const posEditor = document.createElement('div');
|
||||
posEditor.className = 'we-gradient-stop-pos-editor';
|
||||
|
||||
if (isActive) {
|
||||
posEditor.append(selectedStopPosHost);
|
||||
// Avoid resetting while user is typing
|
||||
if (!isPositionInputFocused()) {
|
||||
selectedStopPosInput.value = String(formatPercentValue(stop.position));
|
||||
}
|
||||
}
|
||||
|
||||
pos.append(posStatic, posEditor);
|
||||
|
||||
// Color column
|
||||
const color = document.createElement('div');
|
||||
@@ -2026,7 +2189,14 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
removeStopById(model.id);
|
||||
});
|
||||
|
||||
// Focus the color field input when needed
|
||||
// Focus helpers for position and color inputs
|
||||
const focusSelectedPosInput = () => {
|
||||
queueMicrotask(() => {
|
||||
selectedStopPosInput.focus();
|
||||
selectedStopPosInput.select();
|
||||
});
|
||||
};
|
||||
|
||||
const focusSelectedColorField = () => {
|
||||
queueMicrotask(() => {
|
||||
const input =
|
||||
@@ -2035,11 +2205,12 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
});
|
||||
};
|
||||
|
||||
// Click to select
|
||||
const selectThisRow = (opts?: { focusColor?: boolean }) => {
|
||||
// Click to select (with optional focus target)
|
||||
const selectThisRow = (opts?: { focusColor?: boolean; focusPosition?: boolean }) => {
|
||||
selectedStopId = model.id;
|
||||
updateGradientBar();
|
||||
if (opts?.focusColor) focusSelectedColorField();
|
||||
if (opts?.focusPosition) focusSelectedPosInput();
|
||||
};
|
||||
|
||||
row.addEventListener('click', (event) => {
|
||||
@@ -2049,14 +2220,55 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
});
|
||||
|
||||
row.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (model.id === selectedStopId) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
// Don't hijack keys while user is editing text inputs inside the row
|
||||
if (isTextInputLike(event.target)) return;
|
||||
|
||||
// Arrow key navigation between rows (Phase 9)
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const nextIndex =
|
||||
event.key === 'ArrowUp'
|
||||
? Math.max(0, rowIndex - 1)
|
||||
: Math.min(rows.length - 1, rowIndex + 1);
|
||||
if (nextIndex === rowIndex) return;
|
||||
|
||||
const nextModel = rows[nextIndex]?.model;
|
||||
if (!nextModel) return;
|
||||
|
||||
selectedStopId = nextModel.id;
|
||||
updateGradientBar();
|
||||
|
||||
// Focus the next row after DOM update
|
||||
queueMicrotask(() => {
|
||||
const nextRow = stopsList.querySelector<HTMLElement>(
|
||||
`.we-gradient-stop-row[data-stop-id="${nextModel.id}"]`,
|
||||
);
|
||||
nextRow?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter/Space to select (only if not already selected)
|
||||
if (model.id !== selectedStopId && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
selectThisRow();
|
||||
}
|
||||
});
|
||||
|
||||
// Clicking the color static area selects and focuses the editor
|
||||
// Clicking the position area selects and focuses the position editor
|
||||
posStatic.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (model.id === selectedStopId) {
|
||||
focusSelectedPosInput();
|
||||
return;
|
||||
}
|
||||
selectThisRow({ focusPosition: true });
|
||||
});
|
||||
|
||||
// Clicking the color static area selects and focuses the color editor
|
||||
colorStatic.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -2081,9 +2293,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
stopsHeaderRow.hidden = currentType === 'none';
|
||||
stopsList.hidden = currentType === 'none';
|
||||
stopsAddBtn.disabled = typeSelect.disabled || currentType === 'none';
|
||||
// Phase 4E: hide legacy stop rows (Phase 4H will remove them entirely)
|
||||
stop1Row.hidden = true;
|
||||
stop2Row.hidden = true;
|
||||
}
|
||||
|
||||
function setAllDisabled(disabled: boolean): void {
|
||||
@@ -2092,11 +2301,8 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
shapeSelect.disabled = disabled;
|
||||
posXInput.disabled = disabled;
|
||||
posYInput.disabled = disabled;
|
||||
stop1PosInput.disabled = disabled;
|
||||
stop2PosInput.disabled = disabled;
|
||||
stopsAddBtn.disabled = disabled;
|
||||
stop1ColorField.setDisabled(disabled);
|
||||
stop2ColorField.setDisabled(disabled);
|
||||
selectedStopPosInput.disabled = disabled || currentType === 'none';
|
||||
selectedStopColorField.setDisabled(disabled || currentType === 'none');
|
||||
}
|
||||
|
||||
@@ -2106,26 +2312,23 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
posXInput.value = '';
|
||||
posYInput.value = '';
|
||||
|
||||
stop1PosInput.value = String(DEFAULT_STOP_1.position);
|
||||
stop2PosInput.value = String(DEFAULT_STOP_2.position);
|
||||
|
||||
// Reset stops array with new models (fresh IDs)
|
||||
currentStops = createDefaultStopModels();
|
||||
selectedStopId = currentStops[0]?.id ?? null;
|
||||
|
||||
stop1ColorValue = DEFAULT_STOP_1.color;
|
||||
stop2ColorValue = DEFAULT_STOP_2.color;
|
||||
|
||||
stop1ColorField.setValue(DEFAULT_STOP_1.color);
|
||||
stop2ColorField.setValue(DEFAULT_STOP_2.color);
|
||||
stop1ColorField.setPlaceholder('');
|
||||
stop2ColorField.setPlaceholder('');
|
||||
|
||||
if (!options.skipPreview) {
|
||||
updateGradientBar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the position input is currently focused.
|
||||
* Used to prevent list re-rendering while editing.
|
||||
*/
|
||||
function isPositionInputFocused(): boolean {
|
||||
return isFieldFocused(selectedStopPosInput);
|
||||
}
|
||||
|
||||
function isEditing(): boolean {
|
||||
return (
|
||||
backgroundHandle !== null ||
|
||||
@@ -2134,10 +2337,7 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
isFieldFocused(shapeSelect) ||
|
||||
isFieldFocused(posXInput) ||
|
||||
isFieldFocused(posYInput) ||
|
||||
isFieldFocused(stop1PosInput) ||
|
||||
isFieldFocused(stop2PosInput) ||
|
||||
stop1ColorField.isFocused() ||
|
||||
stop2ColorField.isFocused() ||
|
||||
isPositionInputFocused() ||
|
||||
selectedStopColorField.isFocused()
|
||||
);
|
||||
}
|
||||
@@ -2215,19 +2415,11 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
* Returns GradientStop[] for CSS generation (strips id field).
|
||||
*/
|
||||
function collectCurrentStops(): GradientStop[] {
|
||||
const c1 = stop1ColorValue.trim() || DEFAULT_STOP_1.color;
|
||||
const c2 = stop2ColorValue.trim() || DEFAULT_STOP_2.color;
|
||||
const p1 = clampPercent(parseNumber(stop1PosInput.value) ?? DEFAULT_STOP_1.position);
|
||||
const p2 = clampPercent(parseNumber(stop2PosInput.value) ?? DEFAULT_STOP_2.position);
|
||||
|
||||
const defaultModels = createDefaultStopModels();
|
||||
const baseStops = currentStops.length >= 2 ? currentStops : defaultModels;
|
||||
|
||||
return baseStops.map((s, i) => {
|
||||
if (i === 0) return { color: c1, position: p1 };
|
||||
if (i === 1) return { color: c2, position: p2 };
|
||||
return { color: s.color.trim() || DEFAULT_STOP_1.color, position: clampPercent(s.position) };
|
||||
});
|
||||
const baseStops = currentStops.length >= 2 ? currentStops : createDefaultStopModels();
|
||||
return baseStops.map((s) => ({
|
||||
color: s.color.trim() || DEFAULT_STOP_1.color,
|
||||
position: clampPercent(s.position),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2242,12 +2434,14 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
function previewGradient(): void {
|
||||
if (disposer.isDisposed) return;
|
||||
|
||||
// Avoid re-rendering stops list while dragging or editing the selected ColorField,
|
||||
// otherwise thumbs may lose pointer capture and the input can lose focus/caret.
|
||||
// Avoid re-rendering stops list while dragging, keyboard stepping, or editing stop editors,
|
||||
// otherwise thumbs may lose pointer capture/focus and inputs can lose focus/caret.
|
||||
const isDragging = thumbDrag !== null;
|
||||
const isKeyboardStepping = thumbKeyboard !== null;
|
||||
const isEditingStopFields = selectedStopColorField.isFocused() || isPositionInputFocused();
|
||||
updateGradientBar({
|
||||
preserveThumbs: isDragging,
|
||||
refreshStopsList: isDragging ? false : !selectedStopColorField.isFocused(),
|
||||
preserveThumbs: isDragging || isKeyboardStepping,
|
||||
refreshStopsList: isDragging || isKeyboardStepping ? false : !isEditingStopFields,
|
||||
});
|
||||
|
||||
const target = currentTarget;
|
||||
@@ -2268,8 +2462,10 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
|
||||
if (!target || !target.isConnected) {
|
||||
setAllDisabled(true);
|
||||
currentType = 'none';
|
||||
typeSelect.value = 'none';
|
||||
// Use 'linear' as default when 'none' is not allowed
|
||||
const defaultType = allowNone ? 'none' : 'linear';
|
||||
currentType = defaultType;
|
||||
typeSelect.value = defaultType;
|
||||
resetDefaults();
|
||||
updateRowVisibility();
|
||||
updateGradientBar();
|
||||
@@ -2280,9 +2476,9 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
|
||||
if (isEditing() && !force) return;
|
||||
|
||||
const inlineValue = readInlineValue(target, 'background-image');
|
||||
const inlineValue = readInlineValue(target, cssProperty);
|
||||
const needsComputed = !inlineValue || /\bvar\s*\(/i.test(inlineValue);
|
||||
const computedValue = needsComputed ? readComputedValue(target, 'background-image') : '';
|
||||
const computedValue = needsComputed ? readComputedValue(target, cssProperty) : '';
|
||||
|
||||
const inlineParsed = !isNoneValue(inlineValue) ? parseGradient(inlineValue) : null;
|
||||
const computedParsed = !isNoneValue(computedValue) ? parseGradient(computedValue) : null;
|
||||
@@ -2318,8 +2514,10 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
resetDefaults({ skipPreview: true });
|
||||
|
||||
if (!parsed) {
|
||||
currentType = 'none';
|
||||
typeSelect.value = 'none';
|
||||
// Use 'linear' as default when 'none' is not allowed
|
||||
const defaultType = allowNone ? 'none' : 'linear';
|
||||
currentType = defaultType;
|
||||
typeSelect.value = defaultType;
|
||||
updateRowVisibility();
|
||||
updateGradientBar();
|
||||
return;
|
||||
@@ -2348,27 +2546,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
selectedStopId = currentStops[0]?.id ?? null;
|
||||
}
|
||||
|
||||
// UI currently only shows first 2 stops
|
||||
const stop1 = currentStops[0] ?? DEFAULT_STOP_1;
|
||||
const stop2 = currentStops[1] ?? DEFAULT_STOP_2;
|
||||
|
||||
stop1ColorValue = stop1.color;
|
||||
stop2ColorValue = stop2.color;
|
||||
|
||||
stop1ColorField.setValue(stop1.color);
|
||||
stop2ColorField.setValue(stop2.color);
|
||||
|
||||
// Set placeholders from the mapped values
|
||||
stop1ColorField.setPlaceholder(
|
||||
hasVarInInline && needsColorPlaceholder(stop1.color) ? (stop1.placeholderColor ?? '') : '',
|
||||
);
|
||||
stop2ColorField.setPlaceholder(
|
||||
hasVarInInline && needsColorPlaceholder(stop2.color) ? (stop2.placeholderColor ?? '') : '',
|
||||
);
|
||||
|
||||
stop1PosInput.value = String(stop1.position);
|
||||
stop2PosInput.value = String(stop2.position);
|
||||
|
||||
if (parsed.type === 'linear') {
|
||||
currentType = 'linear';
|
||||
typeSelect.value = 'linear';
|
||||
@@ -2454,8 +2631,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo
|
||||
wireTextInput(angleInput);
|
||||
wireTextInput(posXInput);
|
||||
wireTextInput(posYInput);
|
||||
wireTextInput(stop1PosInput);
|
||||
wireTextInput(stop2PosInput);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stop Add/Delete Interactions (Phase 6)
|
||||
|
||||
+2
-2
@@ -741,8 +741,8 @@ export function createLayoutControl(options: LayoutControlOptions): DesignContro
|
||||
gridGapRow.hidden = true;
|
||||
|
||||
// Adjust gridRow and gapRow to fit in two-column layout
|
||||
gridRow.classList.add('we-grid-gap-col');
|
||||
gapRow.classList.add('we-grid-gap-col');
|
||||
gridRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--grid');
|
||||
gapRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--gap');
|
||||
|
||||
gridGapRow.append(gridRow, gapRow);
|
||||
|
||||
|
||||
+210
-4
@@ -18,6 +18,7 @@ import { Disposer } from '../../../utils/disposables';
|
||||
import type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';
|
||||
import type { DesignTokensService } from '../../../core/design-tokens';
|
||||
import { createColorField, type ColorField } from './color-field';
|
||||
import { createGradientControl } from './gradient-control';
|
||||
import { createInputContainer, type InputContainer } from '../components/input-container';
|
||||
import { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group';
|
||||
import { combineLengthValue, formatLengthForDisplay, hasExplicitUnit } from './css-helpers';
|
||||
@@ -34,6 +35,10 @@ const FONT_WEIGHT_VALUES = ['100', '200', '300', '400', '500', '600', '700', '80
|
||||
const TEXT_ALIGN_VALUES = ['left', 'center', 'right', 'justify'] as const;
|
||||
const VERTICAL_ALIGN_VALUES = ['baseline', 'middle', 'top', 'bottom'] as const;
|
||||
|
||||
/** Text color type: solid uses 'color' property, gradient uses background-clip: text */
|
||||
const TEXT_COLOR_TYPE_VALUES = ['solid', 'gradient'] as const;
|
||||
type TextColorType = (typeof TEXT_COLOR_TYPE_VALUES)[number];
|
||||
|
||||
type TextAlignValue = (typeof TEXT_ALIGN_VALUES)[number];
|
||||
type VerticalAlignValue = (typeof VERTICAL_ALIGN_VALUES)[number];
|
||||
const FONT_FAMILY_PRESET_VALUES = [
|
||||
@@ -258,6 +263,47 @@ function readComputedValue(element: Element, property: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a gradient background-image.
|
||||
*/
|
||||
function isGradientBackgroundValue(raw: string): boolean {
|
||||
return /\b(?:linear|radial|conic)-gradient\s*\(/i.test(raw.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text-fill-color is transparent (for gradient text detection).
|
||||
*/
|
||||
function isTransparentTextFillColor(raw: string): boolean {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (!v) return false;
|
||||
if (v === 'transparent') return true;
|
||||
// Some browsers compute transparent as rgba(..., 0)
|
||||
if (/^rgba\([^)]*,\s*0\s*\)$/.test(v)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer text color type from element's computed styles.
|
||||
* Returns 'gradient' if background-clip: text pattern is detected.
|
||||
*/
|
||||
function inferTextColorType(target: Element): TextColorType {
|
||||
const bgImage =
|
||||
readInlineValue(target, 'background-image') || readComputedValue(target, 'background-image');
|
||||
const bgClip =
|
||||
readInlineValue(target, '-webkit-background-clip') ||
|
||||
readComputedValue(target, '-webkit-background-clip');
|
||||
const textFill =
|
||||
readInlineValue(target, '-webkit-text-fill-color') ||
|
||||
readComputedValue(target, '-webkit-text-fill-color');
|
||||
|
||||
const hasGradientBg =
|
||||
bgImage && bgImage.toLowerCase() !== 'none' && isGradientBackgroundValue(bgImage);
|
||||
const hasClipText = bgClip.toLowerCase().includes('text');
|
||||
const hasTransparentFill = isTransparentTextFillColor(textFill);
|
||||
|
||||
return hasGradientBg && hasClipText && hasTransparentFill ? 'gradient' : 'solid';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory
|
||||
// =============================================================================
|
||||
@@ -274,6 +320,7 @@ export function createTypographyControl(options: TypographyControlOptions): Desi
|
||||
const disposer = new Disposer();
|
||||
|
||||
let currentTarget: Element | null = null;
|
||||
let currentTextColorType: TextColorType = 'solid';
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'we-field-group';
|
||||
@@ -421,6 +468,27 @@ export function createTypographyControl(options: TypographyControlOptions): Desi
|
||||
verticalAlignMount.className = 'we-field-content';
|
||||
verticalAlignRow.append(verticalAlignLabel, verticalAlignMount);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text Color Type selector (solid / gradient)
|
||||
// ---------------------------------------------------------------------------
|
||||
const textColorTypeRow = document.createElement('div');
|
||||
textColorTypeRow.className = 'we-field';
|
||||
|
||||
const textColorTypeLabel = document.createElement('span');
|
||||
textColorTypeLabel.className = 'we-field-label';
|
||||
textColorTypeLabel.textContent = 'Type';
|
||||
|
||||
const textColorTypeSelect = document.createElement('select');
|
||||
textColorTypeSelect.className = 'we-select';
|
||||
textColorTypeSelect.setAttribute('aria-label', 'Text Color Type');
|
||||
for (const v of TEXT_COLOR_TYPE_VALUES) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.textContent = v.charAt(0).toUpperCase() + v.slice(1);
|
||||
textColorTypeSelect.append(opt);
|
||||
}
|
||||
textColorTypeRow.append(textColorTypeLabel, textColorTypeSelect);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color (with ColorField - TokenPill and TokenPicker are now built into ColorField)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -436,15 +504,36 @@ export function createTypographyControl(options: TypographyControlOptions): Desi
|
||||
|
||||
colorRow.append(colorLabel, colorFieldContainer);
|
||||
|
||||
// Gradient mount for text gradient (uses background-image + background-clip: text)
|
||||
const textGradientMount = document.createElement('div');
|
||||
|
||||
// Create combined row for Size and Weight
|
||||
const sizeAndWeightRow = document.createElement('div');
|
||||
sizeAndWeightRow.className = 'we-field-row';
|
||||
fontSizeRow.style.flex = '1';
|
||||
fontSizeRow.style.minWidth = '0';
|
||||
fontWeightRow.style.flex = '1';
|
||||
fontWeightRow.style.minWidth = '0';
|
||||
sizeAndWeightRow.append(fontSizeRow, fontWeightRow);
|
||||
|
||||
// Create combined row for Line Height and Spacing
|
||||
const lineHeightAndSpacingRow = document.createElement('div');
|
||||
lineHeightAndSpacingRow.className = 'we-field-row';
|
||||
lineHeightRow.style.flex = '1';
|
||||
lineHeightRow.style.minWidth = '0';
|
||||
letterSpacingRow.style.flex = '1';
|
||||
letterSpacingRow.style.minWidth = '0';
|
||||
lineHeightAndSpacingRow.append(lineHeightRow, letterSpacingRow);
|
||||
|
||||
root.append(
|
||||
fontFamilyRow,
|
||||
fontSizeRow,
|
||||
fontWeightRow,
|
||||
lineHeightRow,
|
||||
letterSpacingRow,
|
||||
sizeAndWeightRow,
|
||||
lineHeightAndSpacingRow,
|
||||
textAlignRow,
|
||||
verticalAlignRow,
|
||||
textColorTypeRow,
|
||||
colorRow,
|
||||
textGradientMount,
|
||||
);
|
||||
container.append(root);
|
||||
disposer.add(() => root.remove());
|
||||
@@ -514,6 +603,23 @@ export function createTypographyControl(options: TypographyControlOptions): Desi
|
||||
});
|
||||
disposer.add(() => textColorField.dispose());
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Text Gradient Control (uses background-image + background-clip: text)
|
||||
// Note: This intentionally uses background-image which may conflict with
|
||||
// Background control. Users should be aware that text gradient and element
|
||||
// background cannot be used simultaneously on the same element.
|
||||
// -------------------------------------------------------------------------
|
||||
const textGradientControl = createGradientControl({
|
||||
container: textGradientMount,
|
||||
transactionManager,
|
||||
tokensService,
|
||||
property: 'background-image',
|
||||
// Disable 'none' option since transparent text-fill-color with no background
|
||||
// would make text invisible
|
||||
allowNone: false,
|
||||
});
|
||||
disposer.add(() => textGradientControl.dispose());
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Field state map
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -608,6 +714,79 @@ export function createTypographyControl(options: TypographyControlOptions): Desi
|
||||
for (const p of PROPS) commitTransaction(p);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Text Color Type (Solid / Gradient)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update visibility of color-related rows based on currentTextColorType.
|
||||
*/
|
||||
function updateTextColorTypeVisibility(): void {
|
||||
colorRow.hidden = currentTextColorType !== 'solid';
|
||||
textGradientMount.hidden = currentTextColorType !== 'gradient';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set text color type and apply necessary CSS changes.
|
||||
* Uses multiStyle transaction to atomically set background-clip text properties.
|
||||
*/
|
||||
function setTextColorType(type: TextColorType): void {
|
||||
const target = currentTarget;
|
||||
|
||||
currentTextColorType = type;
|
||||
textColorTypeSelect.value = type;
|
||||
updateTextColorTypeVisibility();
|
||||
|
||||
if (!target || !target.isConnected) return;
|
||||
|
||||
// Ensure we don't leave an open 'color' handle when switching modes
|
||||
commitTransaction('color');
|
||||
|
||||
// Use multiStyle to atomically manage text gradient properties
|
||||
const handle = transactionManager.beginMultiStyle(target, [
|
||||
'background-image',
|
||||
'-webkit-background-clip',
|
||||
'-webkit-text-fill-color',
|
||||
]);
|
||||
if (!handle) return;
|
||||
|
||||
if (type === 'solid') {
|
||||
// Clear text gradient properties when switching to solid color
|
||||
handle.set({
|
||||
'background-image': '',
|
||||
'-webkit-background-clip': '',
|
||||
'-webkit-text-fill-color': '',
|
||||
});
|
||||
} else {
|
||||
// Set up text gradient properties
|
||||
const inlineBg = readInlineValue(target, 'background-image');
|
||||
const computedBg = readComputedValue(target, 'background-image');
|
||||
const currentBg = inlineBg || computedBg;
|
||||
|
||||
// Use existing gradient or provide a default
|
||||
const hasValidGradient = currentBg && isGradientBackgroundValue(currentBg);
|
||||
const gradientValue = hasValidGradient
|
||||
? currentBg
|
||||
: 'linear-gradient(90deg, #000000, #ffffff)';
|
||||
|
||||
handle.set({
|
||||
'background-image': gradientValue,
|
||||
'-webkit-background-clip': 'text',
|
||||
'-webkit-text-fill-color': 'transparent',
|
||||
});
|
||||
}
|
||||
|
||||
handle.commit({ merge: true });
|
||||
}
|
||||
|
||||
// Wire text color type selector change event
|
||||
disposer.listen(textColorTypeSelect, 'change', () => {
|
||||
const type = textColorTypeSelect.value as TextColorType;
|
||||
setTextColorType(type);
|
||||
textGradientControl.refresh();
|
||||
syncAllFields();
|
||||
});
|
||||
|
||||
function syncField(property: TypographyProperty, force = false): void {
|
||||
const field = fields[property];
|
||||
const target = currentTarget;
|
||||
@@ -786,6 +965,9 @@ export function createTypographyControl(options: TypographyControlOptions): Desi
|
||||
|
||||
function syncAllFields(): void {
|
||||
for (const p of PROPS) syncField(p);
|
||||
const hasTarget = Boolean(currentTarget && currentTarget.isConnected);
|
||||
textColorTypeSelect.disabled = !hasTarget;
|
||||
updateTextColorTypeVisibility();
|
||||
}
|
||||
|
||||
function wireSelect(property: TypographyProperty): void {
|
||||
@@ -942,12 +1124,36 @@ export function createTypographyControl(options: TypographyControlOptions): Desi
|
||||
if (disposer.isDisposed) return;
|
||||
if (element !== currentTarget) commitAllTransactions();
|
||||
currentTarget = element;
|
||||
|
||||
// Infer text color type from element styles
|
||||
if (element && element.isConnected) {
|
||||
currentTextColorType = inferTextColorType(element);
|
||||
} else {
|
||||
currentTextColorType = 'solid';
|
||||
}
|
||||
textColorTypeSelect.value = currentTextColorType;
|
||||
updateTextColorTypeVisibility();
|
||||
|
||||
// Update gradient control target
|
||||
textGradientControl.setTarget(element);
|
||||
syncAllFields();
|
||||
// Token picker target is now managed by ColorField internally via getTokenTarget callback
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (disposer.isDisposed) return;
|
||||
|
||||
// Re-infer text color type from element to handle external changes (CSS panel, Undo/Redo)
|
||||
const target = currentTarget;
|
||||
if (target && target.isConnected) {
|
||||
const inferredType = inferTextColorType(target);
|
||||
if (inferredType !== currentTextColorType) {
|
||||
currentTextColorType = inferredType;
|
||||
textColorTypeSelect.value = inferredType;
|
||||
}
|
||||
}
|
||||
|
||||
textGradientControl.refresh();
|
||||
syncAllFields();
|
||||
}
|
||||
|
||||
|
||||
+58
-98
@@ -15,6 +15,7 @@
|
||||
|
||||
import { Disposer } from '../../utils/disposables';
|
||||
import { installFloatingDrag, type FloatingPosition } from '../floating-drag';
|
||||
import { createChevronIcon, createCloseIcon, createGripIcon } from '../icons';
|
||||
import type {
|
||||
PropertyPanel,
|
||||
PropertyPanelOptions,
|
||||
@@ -41,15 +42,15 @@ import { createPropsPanel, type PropsPanel } from './props-panel';
|
||||
|
||||
/** Control group configuration */
|
||||
const CONTROL_GROUPS = [
|
||||
{ id: 'position', label: 'Position' },
|
||||
{ id: 'layout', label: 'Layout' },
|
||||
{ id: 'size', label: 'Size' },
|
||||
{ id: 'spacing', label: 'Spacing' },
|
||||
{ id: 'typography', label: 'Typography' },
|
||||
{ id: 'appearance', label: 'Appearance' },
|
||||
{ id: 'border', label: 'Border' },
|
||||
{ id: 'background', label: 'Background' },
|
||||
{ id: 'effects', label: 'Effects' },
|
||||
{ id: 'position', label: 'Position', collapsible: true },
|
||||
{ id: 'layout', label: 'Layout', collapsible: true },
|
||||
{ id: 'size', label: 'Size', collapsible: true },
|
||||
{ id: 'spacing', label: 'Spacing', collapsible: true },
|
||||
{ id: 'typography', label: 'Typography', collapsible: true },
|
||||
{ id: 'appearance', label: 'Appearance', collapsible: true },
|
||||
{ id: 'border', label: 'Border', collapsible: true },
|
||||
{ id: 'background', label: 'Background', collapsible: true },
|
||||
{ id: 'effects', label: 'Effects', collapsible: false },
|
||||
] as const;
|
||||
|
||||
type ControlGroupId = (typeof CONTROL_GROUPS)[number]['id'];
|
||||
@@ -80,46 +81,6 @@ function formatTargetLabel(element: Element): string {
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chevron SVG icon for collapse/expand indicator
|
||||
*/
|
||||
function createChevronIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 20 20');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.classList.add('we-chevron');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M7 8l3 3 3-3');
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
svg.append(path);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create close (X) SVG icon for window close button
|
||||
*/
|
||||
function createCloseIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 20 20');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M6 6l8 8M14 6l-8 8');
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
svg.append(path);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sliders SVG icon for property panel minimize/expand button
|
||||
*/
|
||||
@@ -139,7 +100,7 @@ function createSlidersIcon(): SVGElement {
|
||||
svg.append(lines);
|
||||
|
||||
// Three knob circles at different positions
|
||||
const knobs: Array<[number, number]> = [
|
||||
const knobs: ReadonlyArray<readonly [number, number]> = [
|
||||
[7, 5],
|
||||
[13, 10],
|
||||
[9, 15],
|
||||
@@ -160,41 +121,16 @@ function createSlidersIcon(): SVGElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create grip (drag handle) SVG icon
|
||||
* Create a control group (optionally collapsible)
|
||||
*/
|
||||
function createGripIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 20 20');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// 6 dots in 2 columns
|
||||
const dots: Array<[number, number]> = [
|
||||
[7, 6],
|
||||
[13, 6],
|
||||
[7, 10],
|
||||
[13, 10],
|
||||
[7, 14],
|
||||
[13, 14],
|
||||
];
|
||||
|
||||
for (const [cx, cy] of dots) {
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', String(cx));
|
||||
circle.setAttribute('cy', String(cy));
|
||||
circle.setAttribute('r', '1.4');
|
||||
circle.setAttribute('fill', 'currentColor');
|
||||
svg.append(circle);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collapsible control group
|
||||
*/
|
||||
function createControlGroup(groupId: string, label: string, disposer: Disposer): ControlGroup {
|
||||
function createControlGroup(
|
||||
groupId: string,
|
||||
label: string,
|
||||
disposer: Disposer,
|
||||
opts?: { collapsible?: boolean },
|
||||
): ControlGroup {
|
||||
const uniqueId = `we_group_${groupId}_${++groupIdSeq}`;
|
||||
const collapsible = opts?.collapsible ?? true;
|
||||
let collapsed = false;
|
||||
|
||||
// Group container
|
||||
@@ -203,35 +139,56 @@ function createControlGroup(groupId: string, label: string, disposer: Disposer):
|
||||
root.dataset.group = groupId;
|
||||
root.dataset.collapsed = 'false';
|
||||
|
||||
// Header button (clickable to toggle)
|
||||
const header = document.createElement('button');
|
||||
header.type = 'button';
|
||||
// Header (div wrapper to allow button + actions)
|
||||
const header = document.createElement('div');
|
||||
header.className = 'we-group-header';
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
header.setAttribute('aria-controls', uniqueId);
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.textContent = label;
|
||||
|
||||
header.append(labelSpan, createChevronIcon());
|
||||
// Toggle element (button when collapsible; static label otherwise)
|
||||
let toggleEl: HTMLButtonElement | HTMLDivElement;
|
||||
|
||||
if (collapsible) {
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'we-group-toggle';
|
||||
toggleBtn.setAttribute('aria-expanded', 'true');
|
||||
toggleBtn.setAttribute('aria-controls', uniqueId);
|
||||
toggleBtn.append(labelSpan, createChevronIcon());
|
||||
|
||||
// Toggle handler
|
||||
disposer.listen(toggleBtn, 'click', (event) => {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
});
|
||||
|
||||
toggleEl = toggleBtn;
|
||||
} else {
|
||||
const staticLabel = document.createElement('div');
|
||||
staticLabel.className = 'we-group-toggle we-group-toggle--static';
|
||||
staticLabel.append(labelSpan);
|
||||
toggleEl = staticLabel;
|
||||
}
|
||||
|
||||
// Actions container (for add buttons, etc.)
|
||||
const headerActions = document.createElement('div');
|
||||
headerActions.className = 'we-group-header-actions';
|
||||
|
||||
header.append(toggleEl, headerActions);
|
||||
|
||||
// Body container
|
||||
const body = document.createElement('div');
|
||||
body.className = 'we-group-body';
|
||||
body.id = uniqueId;
|
||||
|
||||
// Toggle handler
|
||||
disposer.listen(header, 'click', (event) => {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
});
|
||||
|
||||
root.append(header, body);
|
||||
|
||||
function setCollapsed(value: boolean): void {
|
||||
if (!collapsible) return;
|
||||
collapsed = value;
|
||||
root.dataset.collapsed = collapsed ? 'true' : 'false';
|
||||
header.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
(toggleEl as HTMLButtonElement).setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
}
|
||||
|
||||
function isCollapsed(): boolean {
|
||||
@@ -239,12 +196,14 @@ function createControlGroup(groupId: string, label: string, disposer: Disposer):
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
if (!collapsible) return;
|
||||
setCollapsed(!collapsed);
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
body,
|
||||
headerActions,
|
||||
setCollapsed,
|
||||
isCollapsed,
|
||||
toggle,
|
||||
@@ -408,8 +367,8 @@ export function createPropertyPanel(options: PropertyPanelOptions): PropertyPane
|
||||
designPanel.dataset.tabContent = 'design';
|
||||
|
||||
// Create control groups
|
||||
for (const { id, label } of CONTROL_GROUPS) {
|
||||
const group = createControlGroup(id, label, disposer);
|
||||
for (const { id, label, collapsible } of CONTROL_GROUPS) {
|
||||
const group = createControlGroup(id, label, disposer, { collapsible });
|
||||
controlGroups.set(id, group);
|
||||
designPanel.append(group.root);
|
||||
}
|
||||
@@ -604,6 +563,7 @@ export function createPropertyPanel(options: PropertyPanelOptions): PropertyPane
|
||||
container: effectsGroup.body,
|
||||
transactionManager: options.transactionManager,
|
||||
tokensService: options.tokensService,
|
||||
headerActionsContainer: effectsGroup.headerActions,
|
||||
});
|
||||
controls.push(effectsControl);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ export interface ControlGroup {
|
||||
/** The body container where controls are mounted */
|
||||
body: HTMLElement;
|
||||
|
||||
/** Optional: Container for header action buttons (e.g., add button) */
|
||||
headerActions?: HTMLElement;
|
||||
|
||||
/** Set collapsed state */
|
||||
setCollapsed(collapsed: boolean): void;
|
||||
|
||||
|
||||
@@ -79,6 +79,15 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
--we-text-secondary: #737373;
|
||||
--we-text-muted: #a3a3a3;
|
||||
|
||||
/* Accent surfaces (used by CSS/Props panels) */
|
||||
--we-accent-info-bg: rgba(59, 130, 246, 0.08);
|
||||
--we-accent-brand-bg: rgba(99, 102, 241, 0.12);
|
||||
--we-accent-brand-border: rgba(99, 102, 241, 0.25);
|
||||
--we-accent-warning-bg: rgba(251, 191, 36, 0.14);
|
||||
--we-accent-warning-border: rgba(251, 191, 36, 0.25);
|
||||
--we-accent-danger-bg: rgba(248, 113, 113, 0.12);
|
||||
--we-accent-danger-border: rgba(248, 113, 113, 0.25);
|
||||
|
||||
/* Shadows - Tailwind-like shadow-xl */
|
||||
--we-shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--we-shadow-panel: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
@@ -418,15 +427,17 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
left: 50%;
|
||||
top: 16px;
|
||||
transform: translateX(-50%);
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
transform-origin: right top;
|
||||
width: auto;
|
||||
max-width: min(720px, calc(100vw - 32px));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--we-surface-bg);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
border-radius: var(--we-radius-panel);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--we-shadow-subtle);
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
@@ -449,21 +460,72 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Minimized toolbar - becomes a small icon button fixed at top-right (left of property panel) */
|
||||
/* Minimized toolbar - collapses from pill to circle */
|
||||
.we-toolbar[data-minimized="true"] {
|
||||
/* Reset to fixed position in top-right */
|
||||
left: auto;
|
||||
right: calc(16px + var(--we-icon-btn-size) + 8px);
|
||||
top: 16px;
|
||||
bottom: auto;
|
||||
transform: none;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
/* Visual style */
|
||||
background: var(--we-surface-bg);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
box-shadow: var(--we-shadow-subtle);
|
||||
z-index: 10;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hide sections in minimized state using CSS for smooth animation */
|
||||
.we-toolbar[data-minimized="true"] .we-toolbar-left,
|
||||
.we-toolbar[data-minimized="true"] .we-toolbar-right {
|
||||
max-width: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.we-toolbar[data-minimized="true"] .we-toolbar-center {
|
||||
flex: 0;
|
||||
max-width: 0;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Minimized + dragged: use floating position from inline styles */
|
||||
.we-toolbar[data-minimized="true"][data-dragged="true"] {
|
||||
left: auto;
|
||||
right: auto;
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
/* Toolbar icon buttons hover effect (builder topbar style) */
|
||||
.we-toolbar .we-icon-btn {
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.we-toolbar .we-icon-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.we-toolbar .we-icon-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Respect reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.we-toolbar,
|
||||
.we-toolbar-left,
|
||||
.we-toolbar-center,
|
||||
.we-toolbar-right,
|
||||
.we-toolbar .we-icon-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.we-toolbar-left,
|
||||
@@ -471,6 +533,12 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* Smooth transition for minimize/restore - cubic-bezier for fast-to-slow deceleration */
|
||||
transition: max-width 350ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 250ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
padding 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
max-width: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.we-toolbar-center {
|
||||
@@ -478,13 +546,12 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Force hidden state for toolbar sections during minimization */
|
||||
.we-toolbar-left[hidden],
|
||||
.we-toolbar-center[hidden],
|
||||
.we-toolbar-right[hidden] {
|
||||
display: none;
|
||||
/* Smooth transition for minimize/restore - cubic-bezier for fast-to-slow deceleration */
|
||||
transition: max-width 350ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 250ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
flex 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.we-toolbar-meta {
|
||||
@@ -923,26 +990,48 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
gap: 6px;
|
||||
padding: 0 0 8px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.we-group-toggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: #333333;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
/* Normal case, matching design spec */
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
|
||||
.we-group-header:hover {
|
||||
.we-group-toggle:hover {
|
||||
color: var(--we-text-primary);
|
||||
}
|
||||
|
||||
.we-group-header:focus-visible {
|
||||
.we-group-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--we-focus-ring);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.we-group-toggle--static {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.we-group-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.we-group-body {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
@@ -1318,14 +1407,14 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hide labels only when both columns are visible (grid mode) */
|
||||
.we-grid-gap-col:not([hidden]) + .we-grid-gap-col:not([hidden]) .we-field-label,
|
||||
.we-grid-gap-col:not([hidden]):has(+ .we-grid-gap-col:not([hidden])) .we-field-label {
|
||||
display: none;
|
||||
/* Keep Grid label space for alignment; hide text only when both columns are visible (grid mode) */
|
||||
.we-grid-gap-col--grid:not([hidden]):has(+ .we-grid-gap-col--gap:not([hidden])) .we-field-label {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.we-grid-gap-col .we-field-content {
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -1362,13 +1451,13 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
padding: 10px;
|
||||
background: var(--we-surface-bg);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
z-index: 30;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.we-grid-dimensions-popover[hidden] {
|
||||
@@ -1498,6 +1587,131 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Effects (Box Shadow List)
|
||||
========================================================================== */
|
||||
|
||||
.we-effects-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.we-effects-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.we-effects-item-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.we-effects-item {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 6px;
|
||||
background: var(--we-control-bg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--we-radius-control);
|
||||
transition: background-color 0.1s ease, border-color 0.1s ease, opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.we-effects-item:hover {
|
||||
background: var(--we-control-bg-hover);
|
||||
}
|
||||
|
||||
.we-effects-item[data-open="true"] {
|
||||
background: var(--we-control-bg-focus);
|
||||
border-color: var(--we-control-border-focus);
|
||||
}
|
||||
|
||||
.we-effects-item[data-enabled="false"] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.we-effects-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
color: var(--we-text-primary);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.we-effects-name:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--we-focus-ring);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.we-effects-icon-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--we-radius-control);
|
||||
color: var(--we-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.1s ease, color 0.1s ease;
|
||||
}
|
||||
|
||||
.we-effects-icon-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: var(--we-text-primary);
|
||||
}
|
||||
|
||||
.we-effects-icon-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--we-focus-ring);
|
||||
}
|
||||
|
||||
.we-effects-icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.we-effects-icon-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.we-effects-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: 220px;
|
||||
max-width: 220px;
|
||||
padding: 10px;
|
||||
background: var(--we-surface-bg);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.we-effects-popover[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.we-effects-popover-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Gradient Preview Bar (Phase 4B)
|
||||
========================================================================== */
|
||||
@@ -1535,6 +1749,7 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
@@ -1546,7 +1761,7 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.15s ease;
|
||||
transition: box-shadow 0.15s ease, z-index 0s;
|
||||
}
|
||||
|
||||
.we-gradient-thumb:hover {
|
||||
@@ -1562,14 +1777,20 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Selected thumb state */
|
||||
/* Selected thumb state - raise above unselected thumbs */
|
||||
.we-gradient-thumb--active {
|
||||
z-index: 2;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(59, 130, 246, 0.4),
|
||||
0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Dragging thumb state - cursor feedback on entire bar */
|
||||
/* Dragging thumb - always on top when overlapping at same position */
|
||||
.we-gradient-thumb--dragging {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Dragging state - cursor feedback on entire bar */
|
||||
.we-gradient-bar--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -1653,6 +1874,9 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
.we-gradient-stop-pos {
|
||||
flex: 0 0 auto;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
@@ -1661,6 +1885,56 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--we-control-bg);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.we-gradient-stop-pos:hover {
|
||||
background: var(--we-control-bg-hover, var(--we-control-bg));
|
||||
}
|
||||
|
||||
.we-gradient-stop-pos:focus-within {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
/* Static position display (visible when row is not selected) */
|
||||
.we-gradient-stop-pos-static {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Position editor slot (visible when row is selected) */
|
||||
.we-gradient-stop-pos-editor {
|
||||
display: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Show editor and hide static in active row */
|
||||
.we-gradient-stop-row--active .we-gradient-stop-pos-static {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.we-gradient-stop-row--active .we-gradient-stop-pos-editor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Position input styling */
|
||||
.we-gradient-stop-pos-input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.we-gradient-stop-pos-input::placeholder {
|
||||
color: var(--we-text-muted);
|
||||
}
|
||||
|
||||
.we-gradient-stop-color {
|
||||
@@ -1805,9 +2079,9 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
border-radius: var(--we-radius-panel);
|
||||
background: var(--we-surface-bg);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1824,8 +2098,8 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||
background: var(--we-accent-brand-bg);
|
||||
border: 1px solid var(--we-accent-brand-border);
|
||||
color: #4338ca;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
@@ -1862,16 +2136,16 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
min-width: 80px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
border-radius: var(--we-radius-control);
|
||||
background: var(--we-control-bg-focus);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.we-class-input:focus {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
border-color: var(--we-control-border-focus);
|
||||
box-shadow: 0 0 0 2px var(--we-focus-ring);
|
||||
}
|
||||
|
||||
.we-class-input:disabled {
|
||||
@@ -1888,9 +2162,9 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 8px;
|
||||
background: var(--we-surface-bg);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
border-radius: var(--we-radius-panel);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
@@ -1925,9 +2199,9 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
|
||||
.we-css-info {
|
||||
padding: 8px 10px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-radius: 6px;
|
||||
color: #64748b;
|
||||
background: var(--we-accent-info-bg);
|
||||
border-radius: var(--we-radius-control);
|
||||
color: var(--we-text-secondary);
|
||||
font-size: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -1946,8 +2220,9 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
|
||||
.we-css-warning {
|
||||
padding: 6px 10px;
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border-radius: 4px;
|
||||
background: var(--we-accent-warning-bg);
|
||||
border: 1px solid var(--we-accent-warning-border);
|
||||
border-radius: var(--we-radius-control);
|
||||
color: #92400e;
|
||||
font-size: 10px;
|
||||
margin-bottom: 4px;
|
||||
@@ -1975,41 +2250,47 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
.we-css-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.we-css-section {
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.we-css-section + .we-css-section {
|
||||
border-top: 1px solid var(--we-border-section);
|
||||
padding-top: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.we-css-section[data-kind="inherited"] {
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.we-css-section-header {
|
||||
padding: 8px 10px;
|
||||
background: rgba(241, 245, 249, 0.95);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
|
||||
padding: 0 0 8px 0;
|
||||
background: transparent;
|
||||
border-bottom: 0;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--we-text-primary);
|
||||
font-size: 11px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.we-css-section-rules {
|
||||
padding: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.we-css-rule {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: rgba(248, 250, 252, 0.6);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.5);
|
||||
background: var(--we-surface-secondary);
|
||||
border-radius: var(--we-radius-control);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
}
|
||||
|
||||
.we-css-rule:last-child {
|
||||
@@ -2017,8 +2298,8 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
}
|
||||
|
||||
.we-css-rule[data-origin="inline"] {
|
||||
background: rgba(254, 243, 199, 0.3);
|
||||
border-color: rgba(251, 191, 36, 0.3);
|
||||
background: var(--we-accent-warning-bg);
|
||||
border-color: var(--we-accent-warning-border);
|
||||
}
|
||||
|
||||
.we-css-rule-header {
|
||||
@@ -2028,7 +2309,7 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px dashed rgba(226, 232, 240, 0.6);
|
||||
border-bottom: 1px dashed var(--we-border-subtle);
|
||||
}
|
||||
|
||||
.we-css-rule-selector {
|
||||
@@ -2049,10 +2330,10 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
}
|
||||
|
||||
.we-css-rule-spec {
|
||||
color: #94a3b8;
|
||||
color: var(--we-text-muted);
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
background: rgba(226, 232, 240, 0.5);
|
||||
background: var(--we-control-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -2421,9 +2702,9 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
}
|
||||
|
||||
.we-props-meta {
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-radius: 10px;
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
border-radius: var(--we-radius-panel);
|
||||
background: var(--we-surface-secondary);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2453,7 +2734,7 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
background: var(--we-accent-brand-bg);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
@@ -2466,18 +2747,18 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
.we-props-warning {
|
||||
font-size: 11px;
|
||||
color: #92400e;
|
||||
background: rgba(251, 191, 36, 0.14);
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
border-radius: 8px;
|
||||
background: var(--we-accent-warning-bg);
|
||||
border: 1px solid var(--we-accent-warning-border);
|
||||
border-radius: var(--we-radius-panel);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.we-props-error {
|
||||
font-size: 11px;
|
||||
color: #b91c1c;
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||
border-radius: 8px;
|
||||
background: var(--we-accent-danger-bg);
|
||||
border: 1px solid var(--we-accent-danger-border);
|
||||
border-radius: var(--we-radius-panel);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
@@ -2489,9 +2770,9 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
}
|
||||
|
||||
.we-props-list {
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid var(--we-border-subtle);
|
||||
border-radius: var(--we-radius-panel);
|
||||
background: var(--we-surface-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -2508,13 +2789,13 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
|
||||
.we-props-group {
|
||||
padding: 6px 10px;
|
||||
background: rgba(241, 245, 249, 0.9);
|
||||
background: var(--we-control-bg);
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.7);
|
||||
border-top: 1px solid var(--we-border-section);
|
||||
}
|
||||
|
||||
.we-props-group:first-child {
|
||||
@@ -2535,7 +2816,7 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.7);
|
||||
border-top: 1px solid var(--we-border-section);
|
||||
}
|
||||
|
||||
.we-props-row:first-child {
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
import type { StructureOperationData } from '@/common/web-editor-types';
|
||||
import { Disposer } from '../utils/disposables';
|
||||
import { installFloatingDrag, type FloatingPosition } from './floating-drag';
|
||||
import {
|
||||
createCloseIcon,
|
||||
createGripIcon,
|
||||
createMinusIcon,
|
||||
createPlusIcon,
|
||||
createRedoIcon,
|
||||
createUndoIcon,
|
||||
} from './icons';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -137,66 +145,6 @@ function formatStatusMessage(base: string, result?: ApplyResult): string {
|
||||
return req ? `${base} (${req})` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create wand (spark) SVG icon for toolbar minimize/expand button
|
||||
*/
|
||||
function createWandIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 20 20');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// Wand diagonal line
|
||||
const wand = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
wand.setAttribute('d', 'M4 16l8-8M6 18l-2-2M12 8l2 2');
|
||||
wand.setAttribute('stroke', 'currentColor');
|
||||
wand.setAttribute('stroke-width', '2');
|
||||
wand.setAttribute('stroke-linecap', 'round');
|
||||
wand.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
// Spark effect at tip
|
||||
const spark = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
spark.setAttribute('d', 'M14 3v3M12.5 4.5h3');
|
||||
spark.setAttribute('stroke', 'currentColor');
|
||||
spark.setAttribute('stroke-width', '2');
|
||||
spark.setAttribute('stroke-linecap', 'round');
|
||||
spark.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
svg.append(wand, spark);
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create grip (drag handle) SVG icon
|
||||
*/
|
||||
function createGripIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 20 20');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// 6 dots in 2 columns
|
||||
const dots: Array<[number, number]> = [
|
||||
[7, 6],
|
||||
[13, 6],
|
||||
[7, 10],
|
||||
[13, 10],
|
||||
[7, 14],
|
||||
[13, 14],
|
||||
];
|
||||
|
||||
for (const [cx, cy] of dots) {
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', String(cx));
|
||||
circle.setAttribute('cy', String(cy));
|
||||
circle.setAttribute('r', '1.4');
|
||||
circle.setAttribute('fill', 'currentColor');
|
||||
svg.append(circle);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Status Reset Timer
|
||||
// =============================================================================
|
||||
@@ -273,7 +221,7 @@ export function createToolbar(options: ToolbarOptions): Toolbar {
|
||||
dragHandle.type = 'button';
|
||||
dragHandle.className = 'we-drag-handle';
|
||||
dragHandle.setAttribute('aria-label', 'Drag toolbar');
|
||||
dragHandle.title = 'Drag';
|
||||
dragHandle.dataset.tooltip = 'Drag';
|
||||
dragHandle.append(createGripIcon());
|
||||
|
||||
const title = document.createElement('div');
|
||||
@@ -313,31 +261,37 @@ export function createToolbar(options: ToolbarOptions): Toolbar {
|
||||
applyBtn.textContent = 'Apply';
|
||||
applyBtn.setAttribute('aria-label', 'Apply changes to code');
|
||||
|
||||
// Undo button (icon style)
|
||||
const undoBtn = document.createElement('button');
|
||||
undoBtn.type = 'button';
|
||||
undoBtn.className = 'we-btn';
|
||||
undoBtn.textContent = 'Undo';
|
||||
undoBtn.className = 'we-icon-btn';
|
||||
undoBtn.setAttribute('aria-label', 'Undo last change');
|
||||
undoBtn.dataset.tooltip = 'Undo';
|
||||
undoBtn.append(createUndoIcon());
|
||||
|
||||
// Redo button (icon style)
|
||||
const redoBtn = document.createElement('button');
|
||||
redoBtn.type = 'button';
|
||||
redoBtn.className = 'we-btn';
|
||||
redoBtn.textContent = 'Redo';
|
||||
redoBtn.className = 'we-icon-btn';
|
||||
redoBtn.setAttribute('aria-label', 'Redo last undone change');
|
||||
redoBtn.dataset.tooltip = 'Redo';
|
||||
redoBtn.append(createRedoIcon());
|
||||
|
||||
// Close button (icon style)
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'we-btn we-btn--danger';
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.className = 'we-icon-btn';
|
||||
closeBtn.setAttribute('aria-label', 'Close Web Editor');
|
||||
closeBtn.dataset.tooltip = 'Close';
|
||||
closeBtn.append(createCloseIcon());
|
||||
|
||||
// Minimize/expand button
|
||||
// Minimize/restore button
|
||||
const minimizeBtn = document.createElement('button');
|
||||
minimizeBtn.type = 'button';
|
||||
minimizeBtn.className = 'we-icon-btn';
|
||||
minimizeBtn.setAttribute('aria-label', 'Minimize toolbar');
|
||||
minimizeBtn.title = 'Minimize';
|
||||
minimizeBtn.append(createWandIcon());
|
||||
minimizeBtn.dataset.tooltip = 'Minimize';
|
||||
minimizeBtn.append(createMinusIcon());
|
||||
|
||||
// ==========================================================================
|
||||
// Structure Dropdown (Phase 5.5)
|
||||
@@ -561,8 +515,8 @@ export function createToolbar(options: ToolbarOptions): Toolbar {
|
||||
function syncFloatingPositionStyles(): void {
|
||||
root.dataset.dragged = floatingPosition ? 'true' : 'false';
|
||||
|
||||
// While minimized, prefer the existing minimized layout (top-right)
|
||||
if (!floatingPosition || minimized) {
|
||||
// No floating position: use CSS-defined positioning
|
||||
if (!floatingPosition) {
|
||||
root.style.left = '';
|
||||
root.style.top = '';
|
||||
root.style.right = '';
|
||||
@@ -571,11 +525,14 @@ export function createToolbar(options: ToolbarOptions): Toolbar {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply floating position (works for both minimized and expanded states)
|
||||
root.style.left = `${floatingPosition.left}px`;
|
||||
root.style.top = `${floatingPosition.top}px`;
|
||||
root.style.right = 'auto';
|
||||
root.style.bottom = 'auto';
|
||||
root.style.transform = 'none';
|
||||
// Don't override transform when minimized (preserves scale animation)
|
||||
// Only clear transform when expanded to remove translateX(-50%)
|
||||
root.style.transform = minimized ? '' : 'none';
|
||||
}
|
||||
|
||||
function setPosition(position: FloatingPosition | null): void {
|
||||
@@ -588,7 +545,7 @@ export function createToolbar(options: ToolbarOptions): Toolbar {
|
||||
return floatingPosition;
|
||||
}
|
||||
|
||||
// Install drag behavior
|
||||
// Install drag behavior for normal state (via drag handle)
|
||||
disposer.add(
|
||||
installFloatingDrag({
|
||||
handleEl: dragHandle,
|
||||
@@ -598,6 +555,13 @@ export function createToolbar(options: ToolbarOptions): Toolbar {
|
||||
}),
|
||||
);
|
||||
|
||||
// Minimized drag state: allows dragging the entire minimized toolbar
|
||||
let minimizedDragCleanup: (() => void) | null = null;
|
||||
disposer.add(() => {
|
||||
minimizedDragCleanup?.();
|
||||
minimizedDragCleanup = null;
|
||||
});
|
||||
|
||||
// Apply initial position (if provided)
|
||||
if (floatingPosition !== null) {
|
||||
setPosition(floatingPosition);
|
||||
@@ -628,32 +592,43 @@ export function createToolbar(options: ToolbarOptions): Toolbar {
|
||||
minimized = value;
|
||||
root.dataset.minimized = minimized ? 'true' : 'false';
|
||||
|
||||
// Update minimize button label and tooltip
|
||||
minimizeBtn.setAttribute('aria-label', minimized ? 'Expand toolbar' : 'Minimize toolbar');
|
||||
minimizeBtn.title = minimized ? 'Expand' : 'Minimize';
|
||||
// Update minimize button label, tooltip, and icon
|
||||
minimizeBtn.setAttribute('aria-label', minimized ? 'Restore toolbar' : 'Minimize toolbar');
|
||||
minimizeBtn.dataset.tooltip = minimized ? 'Restore' : 'Minimize';
|
||||
minimizeBtn.replaceChildren(minimized ? createPlusIcon() : createMinusIcon());
|
||||
|
||||
if (minimized) {
|
||||
// Close dropdown before minimizing
|
||||
setStructureOpen(false);
|
||||
|
||||
// Move minimize button to root, hide sections
|
||||
// Move minimize button to root for minimized state
|
||||
root.append(minimizeBtn);
|
||||
left.hidden = true;
|
||||
center.hidden = true;
|
||||
right.hidden = true;
|
||||
} else {
|
||||
// Restore sections and button position
|
||||
left.hidden = false;
|
||||
center.hidden = false;
|
||||
right.hidden = false;
|
||||
right.insertBefore(minimizeBtn, closeBtn);
|
||||
}
|
||||
|
||||
// Keep minimized layout stable while preserving stored floating position.
|
||||
// When restoring, re-apply stored position (and clamp with current size).
|
||||
if (!minimized && floatingPosition) {
|
||||
setPosition(floatingPosition);
|
||||
// Reset position to top-right corner when minimizing
|
||||
setPosition(null);
|
||||
|
||||
// Install delayed-activation drag on root for minimized state
|
||||
if (!minimizedDragCleanup) {
|
||||
minimizedDragCleanup = installFloatingDrag({
|
||||
handleEl: root,
|
||||
targetEl: root,
|
||||
clampMargin: CLAMP_MARGIN_PX,
|
||||
onPositionChange: (pos) => setPosition(pos),
|
||||
clickThresholdMs: 200,
|
||||
moveThresholdPx: 5,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Restore minimize button position
|
||||
right.insertBefore(minimizeBtn, closeBtn);
|
||||
|
||||
// Remove minimized drag handler
|
||||
if (minimizedDragCleanup) {
|
||||
minimizedDragCleanup();
|
||||
minimizedDragCleanup = null;
|
||||
}
|
||||
|
||||
// Keep position null when restoring to center the toolbar
|
||||
syncFloatingPositionStyles();
|
||||
}
|
||||
}
|
||||
@@ -753,9 +728,20 @@ export function createToolbar(options: ToolbarOptions): Toolbar {
|
||||
// Minimize button
|
||||
disposer.listen(minimizeBtn, 'click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMinimized(!minimized);
|
||||
});
|
||||
|
||||
// Click anywhere on minimized toolbar root to restore (short click, not drag)
|
||||
disposer.listen(root, 'click', (event) => {
|
||||
if (!minimized) return;
|
||||
// Don't restore if clicking minimize button (handled separately)
|
||||
const target = event.target;
|
||||
if (target === minimizeBtn || (target instanceof Node && minimizeBtn.contains(target))) return;
|
||||
event.preventDefault();
|
||||
setMinimized(false);
|
||||
});
|
||||
|
||||
// Structure button - toggle dropdown
|
||||
disposer.listen(structureBtn, 'click', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Test helpers for record-replay contract tests.
|
||||
*
|
||||
* Provides minimal factories and mocks for testing the execution pipeline
|
||||
* without requiring real browser or tool dependencies.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { ExecCtx } from '@/entrypoints/background/record-replay/nodes/types';
|
||||
import type { ActionExecutionContext } from '@/entrypoints/background/record-replay/actions/types';
|
||||
|
||||
/**
|
||||
* Create a minimal ExecCtx for testing
|
||||
*/
|
||||
export function createMockExecCtx(overrides: Partial<ExecCtx> = {}): ExecCtx {
|
||||
return {
|
||||
vars: {},
|
||||
logger: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal ActionExecutionContext for testing
|
||||
*/
|
||||
export function createMockActionCtx(
|
||||
overrides: Partial<ActionExecutionContext> = {},
|
||||
): ActionExecutionContext {
|
||||
return {
|
||||
vars: {},
|
||||
tabId: 1,
|
||||
log: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal Step for testing
|
||||
*/
|
||||
export function createMockStep(type: string, overrides: Record<string, unknown> = {}): any {
|
||||
return {
|
||||
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
type,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal Flow for testing (with nodes/edges for scheduler)
|
||||
*/
|
||||
export function createMockFlow(overrides: Record<string, unknown> = {}): any {
|
||||
const id = `flow_${Date.now()}`;
|
||||
return {
|
||||
id,
|
||||
name: 'Test Flow',
|
||||
version: 1,
|
||||
steps: [],
|
||||
nodes: [],
|
||||
edges: [],
|
||||
variables: [],
|
||||
meta: {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock ActionRegistry for testing
|
||||
*/
|
||||
export function createMockRegistry(handlers: Map<string, any> = new Map()) {
|
||||
const executeFn = vi.fn(async () => ({ status: 'success' as const }));
|
||||
|
||||
return {
|
||||
get: vi.fn((type: string) => handlers.get(type) || { type }),
|
||||
execute: executeFn,
|
||||
register: vi.fn(),
|
||||
has: vi.fn((type: string) => handlers.has(type)),
|
||||
_executeFn: executeFn, // Expose for assertions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Adapter Policy Contract Tests
|
||||
*
|
||||
* Verifies that skipRetry and skipNavWait flags correctly modify
|
||||
* action execution behavior:
|
||||
* - skipRetry: removes action.policy.retry before execution
|
||||
* - skipNavWait: sets ctx.execution.skipNavWait for handlers
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { createStepExecutor } from '@/entrypoints/background/record-replay/actions/adapter';
|
||||
import { createMockExecCtx, createMockStep } from './_test-helpers';
|
||||
|
||||
describe('adapter policy flags contract', () => {
|
||||
let registryExecute: ReturnType<typeof vi.fn>;
|
||||
let mockRegistry: any;
|
||||
|
||||
beforeEach(() => {
|
||||
registryExecute = vi.fn(async () => ({ status: 'success' }));
|
||||
mockRegistry = {
|
||||
get: vi.fn(() => ({ type: 'fill' })), // Returns truthy = handler exists
|
||||
execute: registryExecute,
|
||||
};
|
||||
});
|
||||
|
||||
describe('skipRetry flag', () => {
|
||||
it('removes action.policy.retry when skipRetry is true', async () => {
|
||||
const executor = createStepExecutor(mockRegistry);
|
||||
|
||||
await executor(
|
||||
createMockExecCtx(),
|
||||
createMockStep('fill', {
|
||||
retry: { count: 3, intervalMs: 100, backoff: 'exp' },
|
||||
target: { candidates: [{ type: 'css', value: '#input' }] },
|
||||
value: 'test',
|
||||
}),
|
||||
1, // tabId
|
||||
{ skipRetry: true },
|
||||
);
|
||||
|
||||
expect(registryExecute).toHaveBeenCalledTimes(1);
|
||||
const [, action] = registryExecute.mock.calls[0];
|
||||
expect(action.policy?.retry).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves action.policy.retry when skipRetry is false', async () => {
|
||||
const executor = createStepExecutor(mockRegistry);
|
||||
|
||||
await executor(
|
||||
createMockExecCtx(),
|
||||
createMockStep('fill', {
|
||||
retry: { count: 3, intervalMs: 100, backoff: 'exp' },
|
||||
target: { candidates: [{ type: 'css', value: '#input' }] },
|
||||
value: 'test',
|
||||
}),
|
||||
1,
|
||||
{ skipRetry: false },
|
||||
);
|
||||
|
||||
expect(registryExecute).toHaveBeenCalledTimes(1);
|
||||
const [, action] = registryExecute.mock.calls[0];
|
||||
expect(action.policy?.retry).toBeDefined();
|
||||
expect(action.policy.retry.retries).toBe(3);
|
||||
});
|
||||
|
||||
it('preserves action.policy.retry when skipRetry is not specified', async () => {
|
||||
const executor = createStepExecutor(mockRegistry);
|
||||
|
||||
await executor(
|
||||
createMockExecCtx(),
|
||||
createMockStep('fill', {
|
||||
retry: { count: 2, intervalMs: 50 },
|
||||
target: { candidates: [{ type: 'css', value: '#input' }] },
|
||||
value: 'test',
|
||||
}),
|
||||
1,
|
||||
{}, // No skipRetry
|
||||
);
|
||||
|
||||
const [, action] = registryExecute.mock.calls[0];
|
||||
expect(action.policy?.retry).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipNavWait flag', () => {
|
||||
it('sets ctx.execution.skipNavWait when skipNavWait is true', async () => {
|
||||
const executor = createStepExecutor(mockRegistry);
|
||||
|
||||
await executor(
|
||||
createMockExecCtx(),
|
||||
createMockStep('click', {
|
||||
target: { candidates: [{ type: 'css', value: '#btn' }] },
|
||||
}),
|
||||
1,
|
||||
{ skipNavWait: true },
|
||||
);
|
||||
|
||||
expect(registryExecute).toHaveBeenCalledTimes(1);
|
||||
const [actionCtx] = registryExecute.mock.calls[0];
|
||||
expect(actionCtx.execution?.skipNavWait).toBe(true);
|
||||
});
|
||||
|
||||
it('does not set ctx.execution when skipNavWait is false', async () => {
|
||||
const executor = createStepExecutor(mockRegistry);
|
||||
|
||||
await executor(
|
||||
createMockExecCtx(),
|
||||
createMockStep('click', {
|
||||
target: { candidates: [{ type: 'css', value: '#btn' }] },
|
||||
}),
|
||||
1,
|
||||
{ skipNavWait: false },
|
||||
);
|
||||
|
||||
const [actionCtx] = registryExecute.mock.calls[0];
|
||||
expect(actionCtx.execution).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set ctx.execution when skipNavWait is not specified', async () => {
|
||||
const executor = createStepExecutor(mockRegistry);
|
||||
|
||||
await executor(
|
||||
createMockExecCtx(),
|
||||
createMockStep('navigate', {
|
||||
url: 'https://example.com',
|
||||
}),
|
||||
1,
|
||||
{}, // No skipNavWait
|
||||
);
|
||||
|
||||
const [actionCtx] = registryExecute.mock.calls[0];
|
||||
expect(actionCtx.execution).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined flags', () => {
|
||||
it('applies both skipRetry and skipNavWait together', async () => {
|
||||
const executor = createStepExecutor(mockRegistry);
|
||||
|
||||
await executor(
|
||||
createMockExecCtx(),
|
||||
createMockStep('click', {
|
||||
retry: { count: 5, intervalMs: 200 },
|
||||
target: { candidates: [{ type: 'css', value: '#btn' }] },
|
||||
}),
|
||||
1,
|
||||
{ skipRetry: true, skipNavWait: true },
|
||||
);
|
||||
|
||||
const [actionCtx, action] = registryExecute.mock.calls[0];
|
||||
expect(action.policy?.retry).toBeUndefined();
|
||||
expect(actionCtx.execution?.skipNavWait).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Session DAG Sync Contract Tests
|
||||
*
|
||||
* Verifies that RecordingSessionManager correctly maintains flow.nodes/edges
|
||||
* in lockstep with flow.steps during recording:
|
||||
* - New step → create node + edge from previous node
|
||||
* - Upsert step → update node.config and node.type
|
||||
* - Invariant violation → fallback to full stepsToDAG rebuild
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { RecordingSessionManager } from '@/entrypoints/background/record-replay/recording/session-manager';
|
||||
import type { Flow } from '@/entrypoints/background/record-replay/types';
|
||||
|
||||
function createTestFlow(overrides: Partial<Flow> = {}): Flow {
|
||||
return {
|
||||
id: `test_flow_${Date.now()}`,
|
||||
name: 'Test Flow',
|
||||
version: 1,
|
||||
steps: [],
|
||||
meta: {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestStep(type: string, id?: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: id || `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
type,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RecordingSessionManager DAG sync', () => {
|
||||
let manager: RecordingSessionManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
manager = new RecordingSessionManager();
|
||||
});
|
||||
|
||||
describe('appendSteps creates nodes/edges', () => {
|
||||
it('creates node for first step without edge', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
manager.appendSteps([createTestStep('click', 'step1')]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.nodes).toHaveLength(1);
|
||||
expect(f.nodes![0].id).toBe('step1');
|
||||
expect(f.nodes![0].type).toBe('click');
|
||||
expect(f.edges).toHaveLength(0); // No edge for first step
|
||||
});
|
||||
|
||||
it('creates node and edge for subsequent steps', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
manager.appendSteps([createTestStep('click', 'step1')]);
|
||||
manager.appendSteps([createTestStep('fill', 'step2', { value: 'hello' })]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.nodes).toHaveLength(2);
|
||||
expect(f.nodes![1].id).toBe('step2');
|
||||
expect(f.nodes![1].type).toBe('fill');
|
||||
|
||||
expect(f.edges).toHaveLength(1);
|
||||
expect(f.edges![0].from).toBe('step1');
|
||||
expect(f.edges![0].to).toBe('step2');
|
||||
});
|
||||
|
||||
it('creates correct chain for multiple steps in single batch', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
manager.appendSteps([
|
||||
createTestStep('navigate', 'step1', { url: 'https://example.com' }),
|
||||
createTestStep('click', 'step2'),
|
||||
createTestStep('fill', 'step3', { value: 'test' }),
|
||||
]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.steps).toHaveLength(3);
|
||||
expect(f.nodes).toHaveLength(3);
|
||||
expect(f.edges).toHaveLength(2);
|
||||
|
||||
// Verify chain: step1 → step2 → step3
|
||||
expect(f.edges![0].from).toBe('step1');
|
||||
expect(f.edges![0].to).toBe('step2');
|
||||
expect(f.edges![1].from).toBe('step2');
|
||||
expect(f.edges![1].to).toBe('step3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsert updates node config', () => {
|
||||
it('updates node config when step is upserted', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
// Initial step
|
||||
manager.appendSteps([createTestStep('fill', 'step1', { value: 'initial' })]);
|
||||
|
||||
// Upsert with new value
|
||||
manager.appendSteps([createTestStep('fill', 'step1', { value: 'updated' })]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.steps).toHaveLength(1);
|
||||
expect(f.nodes).toHaveLength(1);
|
||||
expect(f.nodes![0].config?.value).toBe('updated');
|
||||
});
|
||||
|
||||
it('preserves edges when upserting', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
manager.appendSteps([
|
||||
createTestStep('click', 'step1'),
|
||||
createTestStep('fill', 'step2', { value: 'initial' }),
|
||||
]);
|
||||
|
||||
// Upsert step2
|
||||
manager.appendSteps([createTestStep('fill', 'step2', { value: 'updated' })]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.edges).toHaveLength(1);
|
||||
expect(f.edges![0].from).toBe('step1');
|
||||
expect(f.edges![0].to).toBe('step2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invariant handling', () => {
|
||||
it('rebuilds DAG when nodes count mismatches steps', async () => {
|
||||
// Create flow with steps but no nodes (legacy scenario)
|
||||
const flow = createTestFlow({
|
||||
steps: [
|
||||
{ id: 'existing1', type: 'click' } as any,
|
||||
{ id: 'existing2', type: 'fill', value: 'test' } as any,
|
||||
],
|
||||
nodes: undefined,
|
||||
edges: undefined,
|
||||
});
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
// Append new step - should trigger rebuild first
|
||||
manager.appendSteps([createTestStep('navigate', 'step3', { url: 'https://test.com' })]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
// Should have rebuilt: 2 existing + 1 new = 3
|
||||
expect(f.steps).toHaveLength(3);
|
||||
expect(f.nodes).toHaveLength(3);
|
||||
expect(f.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty flow gracefully', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
// Empty appendSteps should be no-op
|
||||
manager.appendSteps([]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.steps).toHaveLength(0);
|
||||
// nodes/edges may be undefined when no steps added, that's valid
|
||||
expect(f.nodes?.length ?? 0).toBe(0);
|
||||
expect(f.edges?.length ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session lifecycle', () => {
|
||||
it('clears caches on session stop', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
manager.appendSteps([createTestStep('click', 'step1')]);
|
||||
|
||||
const stoppedFlow = await manager.stopSession();
|
||||
|
||||
expect(stoppedFlow).not.toBeNull();
|
||||
expect(stoppedFlow!.nodes).toHaveLength(1);
|
||||
|
||||
// After stop, manager should have no flow
|
||||
expect(manager.getFlow()).toBeNull();
|
||||
});
|
||||
|
||||
it('reinitializes caches on new session', async () => {
|
||||
// First session
|
||||
const flow1 = createTestFlow({ id: 'flow1' });
|
||||
await manager.startSession(flow1, 1);
|
||||
manager.appendSteps([createTestStep('click', 'step1')]);
|
||||
await manager.stopSession();
|
||||
|
||||
// Second session - should have fresh state
|
||||
const flow2 = createTestFlow({ id: 'flow2' });
|
||||
await manager.startSession(flow2, 2);
|
||||
manager.appendSteps([createTestStep('fill', 'step2')]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.id).toBe('flow2');
|
||||
expect(f.steps).toHaveLength(1);
|
||||
expect(f.steps[0].id).toBe('step2');
|
||||
expect(f.nodes).toHaveLength(1);
|
||||
expect(f.nodes![0].id).toBe('step2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node type conversion', () => {
|
||||
it('converts valid step types to node types', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
manager.appendSteps([
|
||||
createTestStep('click', 'step1'),
|
||||
createTestStep('fill', 'step2'),
|
||||
createTestStep('navigate', 'step3'),
|
||||
createTestStep('scroll', 'step4'),
|
||||
]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.nodes![0].type).toBe('click');
|
||||
expect(f.nodes![1].type).toBe('fill');
|
||||
expect(f.nodes![2].type).toBe('navigate');
|
||||
expect(f.nodes![3].type).toBe('scroll');
|
||||
});
|
||||
|
||||
it('falls back to script for unknown types', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
manager.appendSteps([createTestStep('unknown_type_xyz', 'step1')]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.nodes![0].type).toBe('script');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge id uniqueness', () => {
|
||||
it('generates unique edge ids', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
manager.appendSteps([
|
||||
createTestStep('click', 's1'),
|
||||
createTestStep('click', 's2'),
|
||||
createTestStep('click', 's3'),
|
||||
createTestStep('click', 's4'),
|
||||
]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
const edgeIds = f.edges!.map((e) => e.id);
|
||||
const uniqueIds = new Set(edgeIds);
|
||||
|
||||
expect(uniqueIds.size).toBe(edgeIds.length);
|
||||
});
|
||||
|
||||
it('uses monotonic sequence for edge ids', async () => {
|
||||
const flow = createTestFlow();
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
// Add steps in multiple batches
|
||||
manager.appendSteps([createTestStep('click', 's1')]);
|
||||
manager.appendSteps([createTestStep('click', 's2')]);
|
||||
manager.appendSteps([createTestStep('click', 's3')]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
// Edge ids should contain sequential numbers
|
||||
expect(f.edges![0].id).toMatch(/^e_0_/);
|
||||
expect(f.edges![1].id).toMatch(/^e_1_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge invariant handling', () => {
|
||||
it('rebuilds DAG when edges are missing', async () => {
|
||||
// Create flow with steps and nodes but missing edges
|
||||
const flow = createTestFlow({
|
||||
steps: [{ id: 's1', type: 'click' } as any, { id: 's2', type: 'fill' } as any],
|
||||
nodes: [
|
||||
{ id: 's1', type: 'click', config: {} },
|
||||
{ id: 's2', type: 'fill', config: {} },
|
||||
],
|
||||
edges: [], // Missing edges!
|
||||
});
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
// Append should trigger rebuild due to edge invariant violation
|
||||
manager.appendSteps([createTestStep('navigate', 's3')]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.steps).toHaveLength(3);
|
||||
expect(f.nodes).toHaveLength(3);
|
||||
// Should have rebuilt edges: s1→s2→s3
|
||||
expect(f.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rebuilds DAG when last edge points to wrong step', async () => {
|
||||
// Create flow with corrupted edge pointing to wrong target
|
||||
const flow = createTestFlow({
|
||||
steps: [{ id: 's1', type: 'click' } as any, { id: 's2', type: 'fill' } as any],
|
||||
nodes: [
|
||||
{ id: 's1', type: 'click', config: {} },
|
||||
{ id: 's2', type: 'fill', config: {} },
|
||||
],
|
||||
edges: [{ id: 'e_0', from: 's1', to: 'wrong_id' }], // Wrong target!
|
||||
});
|
||||
await manager.startSession(flow, 1);
|
||||
|
||||
// Append should trigger rebuild due to edge invariant violation
|
||||
manager.appendSteps([createTestStep('navigate', 's3')]);
|
||||
|
||||
const f = manager.getFlow()!;
|
||||
expect(f.edges).toHaveLength(2);
|
||||
// Last edge should point to last step
|
||||
expect(f.edges![1].to).toBe('s3');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Step Executor Routing Contract Tests
|
||||
*
|
||||
* Verifies that step execution routes correctly based on ExecutionModeConfig:
|
||||
* - legacy mode: always uses legacy executeStep
|
||||
* - hybrid mode: uses actions for allowlisted types, legacy for others
|
||||
* - actions mode: always uses ActionRegistry (strict)
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock legacy executeStep - must be defined inline in vi.mock factory
|
||||
vi.mock('@/entrypoints/background/record-replay/nodes', () => ({
|
||||
executeStep: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
// Mock createStepExecutor from adapter - must be defined inline in vi.mock factory
|
||||
vi.mock('@/entrypoints/background/record-replay/actions/adapter', () => ({
|
||||
createStepExecutor: vi.fn(() => vi.fn(async () => ({ supported: true, result: {} }))),
|
||||
isActionSupported: vi.fn((type: string) => {
|
||||
const supported = ['fill', 'key', 'scroll', 'click', 'navigate', 'delay', 'wait'];
|
||||
return supported.includes(type);
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createMockExecCtx, createMockStep, createMockRegistry } from './_test-helpers';
|
||||
import {
|
||||
DEFAULT_EXECUTION_MODE_CONFIG,
|
||||
createHybridConfig,
|
||||
createActionsOnlyConfig,
|
||||
MINIMAL_HYBRID_ACTION_TYPES,
|
||||
} from '@/entrypoints/background/record-replay/engine/execution-mode';
|
||||
import {
|
||||
LegacyStepExecutor,
|
||||
ActionsStepExecutor,
|
||||
HybridStepExecutor,
|
||||
createExecutor,
|
||||
} from '@/entrypoints/background/record-replay/engine/runners/step-executor';
|
||||
import { executeStep as legacyExecuteStep } from '@/entrypoints/background/record-replay/nodes';
|
||||
import { createStepExecutor as createAdapterExecutor } from '@/entrypoints/background/record-replay/actions/adapter';
|
||||
|
||||
describe('ExecutionModeConfig contract', () => {
|
||||
describe('DEFAULT_EXECUTION_MODE_CONFIG', () => {
|
||||
it('defaults to legacy mode', () => {
|
||||
expect(DEFAULT_EXECUTION_MODE_CONFIG.mode).toBe('legacy');
|
||||
});
|
||||
|
||||
it('defaults skipActionsRetry to true', () => {
|
||||
expect(DEFAULT_EXECUTION_MODE_CONFIG.skipActionsRetry).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults skipActionsNavWait to true', () => {
|
||||
expect(DEFAULT_EXECUTION_MODE_CONFIG.skipActionsNavWait).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHybridConfig', () => {
|
||||
it('sets mode to hybrid', () => {
|
||||
const config = createHybridConfig();
|
||||
expect(config.mode).toBe('hybrid');
|
||||
});
|
||||
|
||||
it('uses MINIMAL_HYBRID_ACTION_TYPES as default allowlist', () => {
|
||||
const config = createHybridConfig();
|
||||
expect(config.actionsAllowlist).toBeDefined();
|
||||
expect(config.actionsAllowlist?.has('fill')).toBe(true);
|
||||
expect(config.actionsAllowlist?.has('key')).toBe(true);
|
||||
expect(config.actionsAllowlist?.has('scroll')).toBe(true);
|
||||
// High-risk types should NOT be in minimal allowlist
|
||||
expect(config.actionsAllowlist?.has('click')).toBe(false);
|
||||
expect(config.actionsAllowlist?.has('navigate')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows overriding actionsAllowlist', () => {
|
||||
const config = createHybridConfig({
|
||||
actionsAllowlist: new Set(['fill', 'click']),
|
||||
});
|
||||
expect(config.actionsAllowlist?.has('fill')).toBe(true);
|
||||
expect(config.actionsAllowlist?.has('click')).toBe(true);
|
||||
expect(config.actionsAllowlist?.has('key')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createActionsOnlyConfig', () => {
|
||||
it('sets mode to actions', () => {
|
||||
const config = createActionsOnlyConfig();
|
||||
expect(config.mode).toBe('actions');
|
||||
});
|
||||
|
||||
it('keeps StepRunner as policy authority (skip flags true)', () => {
|
||||
const config = createActionsOnlyConfig();
|
||||
expect(config.skipActionsRetry).toBe(true);
|
||||
expect(config.skipActionsNavWait).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LegacyStepExecutor', () => {
|
||||
const mockLegacyExecuteStep = legacyExecuteStep as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLegacyExecuteStep.mockClear();
|
||||
});
|
||||
|
||||
it('always uses legacy executeStep', async () => {
|
||||
const executor = new LegacyStepExecutor();
|
||||
const ctx = createMockExecCtx();
|
||||
const step = createMockStep('fill');
|
||||
|
||||
await executor.execute(ctx, step, { tabId: 1 });
|
||||
|
||||
expect(mockLegacyExecuteStep).toHaveBeenCalledWith(ctx, step);
|
||||
});
|
||||
|
||||
it('returns executor type as legacy', async () => {
|
||||
const executor = new LegacyStepExecutor();
|
||||
const result = await executor.execute(createMockExecCtx(), createMockStep('click'), {
|
||||
tabId: 1,
|
||||
});
|
||||
|
||||
expect(result.executor).toBe('legacy');
|
||||
});
|
||||
|
||||
it('supports all step types', () => {
|
||||
const executor = new LegacyStepExecutor();
|
||||
expect(executor.supports('fill')).toBe(true);
|
||||
expect(executor.supports('unknown_type')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HybridStepExecutor routing', () => {
|
||||
const mockLegacyExecuteStep = legacyExecuteStep as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLegacyExecuteStep.mockClear();
|
||||
});
|
||||
|
||||
it('uses legacy for non-allowlisted types', async () => {
|
||||
const config = createHybridConfig({ actionsAllowlist: new Set(['fill']) });
|
||||
const mockReg = createMockRegistry();
|
||||
const executor = new HybridStepExecutor(mockReg as any, config);
|
||||
|
||||
await executor.execute(
|
||||
createMockExecCtx(),
|
||||
createMockStep('click', { target: { candidates: [] } }),
|
||||
{ tabId: 1 },
|
||||
);
|
||||
|
||||
expect(mockLegacyExecuteStep).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns legacy executor type for non-allowlisted types', async () => {
|
||||
const config = createHybridConfig({ actionsAllowlist: new Set(['fill']) });
|
||||
const mockReg = createMockRegistry();
|
||||
const executor = new HybridStepExecutor(mockReg as any, config);
|
||||
|
||||
const result = await executor.execute(
|
||||
createMockExecCtx(),
|
||||
createMockStep('navigate', { url: 'https://example.com' }),
|
||||
{ tabId: 1 },
|
||||
);
|
||||
|
||||
expect(result.executor).toBe('legacy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createExecutor factory', () => {
|
||||
it('creates LegacyStepExecutor for legacy mode', () => {
|
||||
const executor = createExecutor({ ...DEFAULT_EXECUTION_MODE_CONFIG, mode: 'legacy' });
|
||||
expect(executor).toBeInstanceOf(LegacyStepExecutor);
|
||||
});
|
||||
|
||||
it('creates ActionsStepExecutor for actions mode', () => {
|
||||
const mockReg = createMockRegistry();
|
||||
const executor = createExecutor(createActionsOnlyConfig(), mockReg as any);
|
||||
expect(executor).toBeInstanceOf(ActionsStepExecutor);
|
||||
});
|
||||
|
||||
it('creates HybridStepExecutor for hybrid mode', () => {
|
||||
const mockReg = createMockRegistry();
|
||||
const executor = createExecutor(createHybridConfig(), mockReg as any);
|
||||
expect(executor).toBeInstanceOf(HybridStepExecutor);
|
||||
});
|
||||
|
||||
it('throws if actions mode has no registry', () => {
|
||||
expect(() => createExecutor(createActionsOnlyConfig())).toThrow(
|
||||
'ActionRegistry required for actions execution mode',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if hybrid mode has no registry', () => {
|
||||
expect(() => createExecutor(createHybridConfig())).toThrow(
|
||||
'ActionRegistry required for hybrid execution mode',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MINIMAL_HYBRID_ACTION_TYPES', () => {
|
||||
it('contains only low-risk action types', () => {
|
||||
const expected = ['fill', 'key', 'scroll', 'drag', 'wait', 'delay', 'screenshot', 'assert'];
|
||||
for (const type of expected) {
|
||||
expect(MINIMAL_HYBRID_ACTION_TYPES.has(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes high-risk types (navigate, click, tab management)', () => {
|
||||
const excluded = ['navigate', 'click', 'dblclick', 'openTab', 'switchTab', 'closeTab'];
|
||||
for (const type of excluded) {
|
||||
expect(MINIMAL_HYBRID_ACTION_TYPES.has(type)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,9 @@
|
||||
| Phase 1-4E | ✅ 已完成 | 核心 UI 重构(数据结构、解析、预览条、Thumbs、Stops列表、ColorField绑定) |
|
||||
| Phase 5 | ✅ 已完成 | Thumb 拖拽实现 |
|
||||
| Phase 6 | ✅ 已完成 | Add/Delete Stop 功能 |
|
||||
| Phase 7 | ⏳ 待开始 | Position 输入编辑 |
|
||||
| Phase 8 | ⏳ 待开始 | 清理旧代码路径(移除 stop1Row/stop2Row 等遗留代码) |
|
||||
| Phase 9 | ⏳ 待开始 | 边界情况与可访问性 |
|
||||
| Phase 7 | ✅ 已完成 | Position 输入编辑 |
|
||||
| Phase 8 | ✅ 已完成 | 清理旧代码路径(移除 stop1Row/stop2Row 等遗留代码) |
|
||||
| Phase 9 | ✅ 已完成 | 边界情况与可访问性 |
|
||||
|
||||
---
|
||||
|
||||
@@ -270,35 +270,42 @@ disposer.listen(root, 'keydown', (event) => {
|
||||
|
||||
---
|
||||
|
||||
## 待完成任务
|
||||
## 已完成任务
|
||||
|
||||
### Phase 7: Position 输入编辑
|
||||
### Phase 7: Position 输入编辑 ✅
|
||||
|
||||
当前 stops list 中的 position 显示为静态文本。需要实现:
|
||||
实现了 stops list 中的 position 可编辑功能:
|
||||
|
||||
- [ ] 点击 position 区域可编辑
|
||||
- [ ] 输入新值后更新模型和 UI
|
||||
- [ ] 输入验证(0-100 范围)
|
||||
- [ ] Enter 提交,Escape 取消
|
||||
- [x] 点击 position 区域可编辑(单例 `selectedStopPosInput` + host 复挂载模式)
|
||||
- [x] 输入新值后更新模型和 UI(`setStopPositionById` + `previewGradient`)
|
||||
- [x] 输入验证(0-100 范围,通过 `clampPercent` + `wireNumberStepping`)
|
||||
- [x] Enter 提交,Escape 取消(`commitSelectedStopPosition` / `cancelSelectedStopPosition`)
|
||||
- [x] commit-time 排序(`sortCurrentStopsByPosition`)
|
||||
- [x] 聚焦 gating 防止列表重建打断编辑
|
||||
|
||||
### Phase 8: 清理旧代码路径
|
||||
### Phase 8: 清理旧代码路径 ✅
|
||||
|
||||
移除遗留的 2-stop 硬编码:
|
||||
移除了所有遗留的 2-stop 硬编码:
|
||||
|
||||
- [ ] 移除 `stop1Row`, `stop2Row` DOM 元素
|
||||
- [ ] 移除 `stop1ColorValue`, `stop2ColorValue` 变量
|
||||
- [ ] 移除 `stop1ColorField`, `stop2ColorField`
|
||||
- [ ] 移除 `stop1PosInput`, `stop2PosInput`
|
||||
- [ ] 清理 `collectCurrentStops()` 中的遗留逻辑
|
||||
- [ ] 清理 `syncLegacyStopFieldsFromModels()`
|
||||
- [x] 移除 `stop1Row`, `stop2Row` DOM 元素及 `createStopRow()` 函数
|
||||
- [x] 移除 `stop1ColorValue`, `stop2ColorValue` 变量
|
||||
- [x] 移除 `stop1ColorField`, `stop2ColorField` 及其 dispose
|
||||
- [x] 移除 `stop1PosInput`, `stop2PosInput` 及其 wireNumberStepping/wireTextInput
|
||||
- [x] 清理 `collectCurrentStops()` - 直接从 `currentStops` 读取
|
||||
- [x] 删除 `syncLegacyStopFieldsFromModels()` 函数
|
||||
- [x] 清理各函数中的 legacy 同步代码
|
||||
|
||||
### Phase 9: 边界情况与可访问性
|
||||
### Phase 9: 边界情况与可访问性 ✅
|
||||
|
||||
- [ ] 拖拽越界处理优化
|
||||
- [ ] 两个停止点位置相同时的 UI 处理
|
||||
- [ ] 完善 `aria-label` 属性
|
||||
- [ ] 键盘导航支持(Tab, 方向键)
|
||||
- [ ] 屏幕阅读器测试
|
||||
实现了可访问性和边界情况处理:
|
||||
|
||||
- [x] Thumb 重叠时选中态置顶(z-index 层级:默认1, active 2, dragging 3)
|
||||
- [x] Thumb slider ARIA 属性(role="slider", aria-valuemin/max/now/text, aria-orientation)
|
||||
- [x] Thumb 方向键调整 position(ArrowLeft/Right/Up/Down 步进1,Shift 步进10)
|
||||
- [x] Thumb keyboard session 管理(类似 drag session,支持 Escape 取消)
|
||||
- [x] Stops list 方向键导航(ArrowUp/Down 切换选中行)
|
||||
- [x] 防御式检查(handleThumbPointerDown 添加 disabled/none 状态检查)
|
||||
- [x] Thumb focus/blur 事件处理(聚焦选中,blur 提交)
|
||||
|
||||
---
|
||||
|
||||
@@ -342,6 +349,76 @@ rollbackTransaction(); // 回滚(如 Escape 取消)
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: 渐变色支持扩展 ✅
|
||||
|
||||
### 概述
|
||||
|
||||
将渐变色支持扩展到属性面板中所有使用颜色的地方,包括边框颜色和文字颜色。
|
||||
|
||||
### 实现详情
|
||||
|
||||
#### 10.1 gradient-control.ts 参数化
|
||||
|
||||
新增配置选项使 GradientControl 可复用:
|
||||
|
||||
```typescript
|
||||
interface GradientControlOptions {
|
||||
// ... existing options
|
||||
property?: string; // CSS 属性,默认 'background-image'
|
||||
allowNone?: boolean; // 是否显示 None 选项,默认 true
|
||||
}
|
||||
```
|
||||
|
||||
- `property`: 用于 border-image-source 等非 background-image 场景
|
||||
- `allowNone`: 用于 text gradient 场景(禁用 None 避免文字不可见)
|
||||
|
||||
#### 10.2 border-control.ts 渐变支持
|
||||
|
||||
**CSS 实现方案**: 使用 `border-image-source` + `border-image-slice: 1`
|
||||
|
||||
**UI 变更**:
|
||||
|
||||
- 新增 "Type" 选择器行(solid / gradient)
|
||||
- gradient 模式下显示 GradientControl
|
||||
- gradient 模式下锁定 Edge 为 "all"(border-image 不支持 per-edge)
|
||||
|
||||
**关键函数**:
|
||||
|
||||
- `inferBorderColorType()`: 从 border-image-source 推断颜色类型
|
||||
- `setColorType()`: 使用 multiStyle 事务切换模式
|
||||
- `updateEdgeSelectorState()`: gradient 模式下禁用 edge 选择
|
||||
|
||||
#### 10.3 typography-control.ts 渐变支持
|
||||
|
||||
**CSS 实现方案**: 使用 `background-image` + `-webkit-background-clip: text` + `-webkit-text-fill-color: transparent`
|
||||
|
||||
**UI 变更**:
|
||||
|
||||
- 新增 "Type" 选择器行(solid / gradient)
|
||||
- gradient 模式下显示 GradientControl(allowNone: false)
|
||||
- solid 模式下显示原有 ColorField
|
||||
|
||||
**关键函数**:
|
||||
|
||||
- `inferTextColorType()`: 检测 background-clip: text 模式
|
||||
- `setTextColorType()`: 使用 multiStyle 事务切换模式
|
||||
- `isGradientBackgroundValue()`: 检测渐变背景值
|
||||
- `isTransparentTextFillColor()`: 检测透明文字填充色
|
||||
|
||||
**已知限制**:
|
||||
|
||||
- Text gradient 与 Background 控件共用 `background-image` 属性
|
||||
- 同一元素不能同时使用 text gradient 和 element background
|
||||
- 这是 CSS 本身的限制,在文档中已明确说明
|
||||
|
||||
### 技术要点
|
||||
|
||||
1. **multiStyle 事务**: 切换模式时原子设置多个相关属性
|
||||
2. **refresh 重推断**: 处理外部变更(CSS 面板、Undo/Redo)
|
||||
3. **dispose 管理**: 所有新增控件都正确注册 dispose
|
||||
|
||||
---
|
||||
|
||||
## 参考
|
||||
|
||||
- 参考图片: `linear.png`
|
||||
|
||||
+27
-22
@@ -633,17 +633,20 @@ refId?: string; // 聚焦到特定节点的子树
|
||||
|
||||
**完成情况**:
|
||||
|
||||
| 子任务 | 状态 | 说明 |
|
||||
| ----------------- | --------- | ----------------------------------------------------------------------- |
|
||||
| webRequest 版抓包 | ✅ 已完成 | Schema 已增强,添加 maxCaptureTime/inactivityTimeout/includeStatic 参数 |
|
||||
| Debugger 版抓包 | ✅ 已完成 | Schema 已增强,添加 maxCaptureTime/inactivityTimeout/includeStatic 参数 |
|
||||
| 统一过滤配置 | ✅ 已完成 | 过滤配置已统一到 `constants.ts` 的 `NETWORK_FILTERS` |
|
||||
| Schema 描述增强 | ✅ 已完成 | 明确说明两个工具的区别和使用场景 |
|
||||
| 子任务 | 状态 | 说明 |
|
||||
| ----------------- | --------- | ---------------------------------------------------------------------------- |
|
||||
| webRequest 版抓包 | ✅ 已完成 | Schema 已增强,添加 maxCaptureTime/inactivityTimeout/includeStatic 参数 |
|
||||
| Debugger 版抓包 | ✅ 已完成 | Schema 已增强,添加 maxCaptureTime/inactivityTimeout/includeStatic 参数 |
|
||||
| 统一过滤配置 | ✅ 已完成 | 过滤配置已统一到 `constants.ts` 的 `NETWORK_FILTERS` |
|
||||
| Schema 描述增强 | ✅ 已完成 | 明确说明两个工具的区别和使用场景 |
|
||||
| **统一接口** | ✅ 已完成 | 创建 `chrome_network_capture` 统一工具,通过 `needResponseBody` 参数选择后端 |
|
||||
|
||||
**决策调整**: 保留两套工具(webRequest 和 Debugger),通过增强描述引导用户选择:
|
||||
**最终实现**: 创建了统一的 `chrome_network_capture` 工具:
|
||||
|
||||
- `chrome_network_capture_start/stop`: 轻量级,不占用 debugger,无 responseBody
|
||||
- `chrome_network_debugger_start/stop`: 支持 responseBody,但会占用 debugger
|
||||
- **接口**: `action: 'start' | 'stop'` + `needResponseBody?: boolean`
|
||||
- `needResponseBody=false`(默认): 使用 webRequest API(轻量,不占用 debugger)
|
||||
- `needResponseBody=true`: 使用 Debugger API(可以获取 response body)
|
||||
- 原来的 4 个工具(`chrome_network_capture_start/stop`、`chrome_network_debugger_start/stop`)从 TOOL_SCHEMAS 移除,仅供内部使用
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
@@ -824,7 +827,7 @@ refId?: string; // 聚焦到特定节点的子树
|
||||
| 输出脱敏 | cookie/token/JWT/Base64/Hex | 同等覆盖 | ✅ 一致 |
|
||||
| 输出限长 | 50KB 固定 | 50KB 默认,可配 `maxOutputBytes` | ✅ 项目更灵活 |
|
||||
| 超时 | 10s 固定 | 15s 默认,可配 `timeoutMs` | ⚠️ 默认值不同 |
|
||||
| 返回结构 | 含 `tabContext.availableTabs` | 无 tab 列表 | ❌ 缺失 |
|
||||
| 返回结构 | 含 `tabContext.availableTabs` | 无 tab 列表 | 不需要 |
|
||||
| 参数契约 | `action/text` | `code` | ⚠️ 接口不兼容 |
|
||||
|
||||
### 2. `chrome_gif_recorder` 差异
|
||||
@@ -865,26 +868,28 @@ refId?: string; // 聚焦到特定节点的子树
|
||||
|
||||
### 5. Network Capture 差异
|
||||
|
||||
| 维度 | mcp-tools.js | 项目实现 | 影响 |
|
||||
| -------- | ------------ | ---------------------------------------- | --------------- |
|
||||
| 统一开关 | 无 | 未实现 `needResponseBody` 统一开关 | ⚠️ 保持两套工具 |
|
||||
| 过滤配置 | 统一 | Debugger 版未复用 `NETWORK_FILTERS` 常量 | ⚠️ 代码不一致 |
|
||||
| 维度 | mcp-tools.js | 项目实现 | 影响 |
|
||||
| -------- | ------------ | ------------------------------------------------------ | --------------- |
|
||||
| 统一开关 | 无 | 未实现 `needResponseBody` 统一开关 | ⚠️ 保持两套工具 |
|
||||
| 过滤配置 | 统一 | ~~Debugger 版未复用 `NETWORK_FILTERS` 常量~~ ✅ 已修复 | ✅ 代码一致 |
|
||||
|
||||
---
|
||||
|
||||
## 九、后续优化建议
|
||||
|
||||
### 高优先级
|
||||
### 已完成 ✅
|
||||
|
||||
1. **GIF stop 补末帧**:与 mcp-tools 行为一致,确保录制完整性
|
||||
2. **Network 过滤配置统一**:Debugger 版复用 `NETWORK_FILTERS` 常量
|
||||
1. **Network 过滤配置统一**:Debugger 版已复用 `NETWORK_FILTERS` 常量,修复了 `facebook.com/tr` 匹配 bug
|
||||
2. **GIF stop 补末帧**:与 mcp-tools 行为一致,确保录制完整性
|
||||
3. **Computer hover scrollIntoView**:ref/selector 路径现在会先滚动元素到视口中心再 hover
|
||||
4. **Console 透出 dropped 计数**:buffer 模式返回 `droppedMessageCount/droppedExceptionCount`
|
||||
|
||||
### 中优先级
|
||||
### 中优先级(待定)
|
||||
|
||||
3. **Console buffer 容量扩大**:考虑从 2000 提升到 5000
|
||||
4. **GIF 增加 quality 参数**:控制输出质量和文件大小
|
||||
5. **Console buffer 容量扩大**:考虑从 2000 提升到 5000(需根据实际溢出情况决定)
|
||||
6. **GIF 增加 quality 参数**:控制输出质量和文件大小
|
||||
|
||||
### 低优先级(接口兼容性)
|
||||
|
||||
5. **tabContext 返回**:javascript/console 等工具增加 availableTabs 返回
|
||||
6. **zoom/modifiers 接口**:当前对象形式更 TS 友好,暂不调整
|
||||
7. **tabContext 返回**:javascript/console 等工具增加 availableTabs 返回
|
||||
8. **zoom/modifiers 接口**:当前对象形式更 TS 友好,暂不调整
|
||||
|
||||
@@ -12,6 +12,8 @@ export const TOOL_NAMES = {
|
||||
CLICK: 'chrome_click_element',
|
||||
FILL: 'chrome_fill_or_select',
|
||||
GET_INTERACTIVE_ELEMENTS: 'chrome_get_interactive_elements',
|
||||
NETWORK_CAPTURE: 'chrome_network_capture',
|
||||
// Legacy tool names (kept for internal use, not exposed in TOOL_SCHEMAS)
|
||||
NETWORK_CAPTURE_START: 'chrome_network_capture_start',
|
||||
NETWORK_CAPTURE_STOP: 'chrome_network_capture_stop',
|
||||
NETWORK_REQUEST: 'chrome_network_request',
|
||||
@@ -594,16 +596,26 @@ export const TOOL_SCHEMAS: Tool[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START,
|
||||
name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE,
|
||||
description:
|
||||
'Start capturing network requests using Chrome Debugger API. Use this when you need responseBody content. Note: occupies debugger, may conflict with DevTools.',
|
||||
'Unified network capture tool. Use action="start" to begin capturing, action="stop" to end and retrieve results. Set needResponseBody=true to capture response bodies (uses Debugger API, may conflict with DevTools). Default mode uses webRequest API (lightweight, no debugger conflict, but no response body).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['start', 'stop'],
|
||||
description: 'Action to perform: "start" begins capture, "stop" ends and returns results',
|
||||
},
|
||||
needResponseBody: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'When true, captures response body using Debugger API (default: false). Only use when you need to inspect response content.',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description:
|
||||
'URL to capture network requests from. If not provided, uses the current active tab',
|
||||
'URL to capture network requests from. For action="start". If not provided, uses the current active tab.',
|
||||
},
|
||||
maxCaptureTime: {
|
||||
type: 'number',
|
||||
@@ -618,17 +630,7 @@ export const TOOL_SCHEMAS: Tool[] = [
|
||||
description: 'Include static resources like images/scripts/styles (default: false)',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP,
|
||||
description:
|
||||
'Stop capturing network requests using Chrome Debugger API and return the captured data with responseBody',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
required: ['action'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -644,44 +646,6 @@ export const TOOL_SCHEMAS: Tool[] = [
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START,
|
||||
description:
|
||||
'Start capturing network requests using Chrome webRequest API. Lightweight, does not occupy debugger. Use chrome_network_debugger_start if you need responseBody.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description:
|
||||
'URL to capture network requests from. If not provided, uses the current active tab',
|
||||
},
|
||||
maxCaptureTime: {
|
||||
type: 'number',
|
||||
description: 'Maximum capture time in milliseconds (default: 180000)',
|
||||
},
|
||||
inactivityTimeout: {
|
||||
type: 'number',
|
||||
description: 'Stop after inactivity in milliseconds (default: 60000). Set 0 to disable.',
|
||||
},
|
||||
includeStatic: {
|
||||
type: 'boolean',
|
||||
description: 'Include static resources like images/scripts/styles (default: false)',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP,
|
||||
description:
|
||||
'Stop capturing network requests using webRequest API and return the captured data (without responseBody)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.HISTORY,
|
||||
description: 'Retrieve and search browsing history from Chrome',
|
||||
|
||||
+603
-29
@@ -12,6 +12,59 @@
|
||||
|
||||
---
|
||||
|
||||
## 整体进度概览
|
||||
|
||||
| 阶段 | 状态 | 完成时间 | 主要内容 |
|
||||
| ---------------------- | --------- | -------- | -------------------------------------------------------------- |
|
||||
| Phase 1.1 Action 系统 | ✅ 完成 | - | 27 种 Action 类型定义、执行器注册表 |
|
||||
| Phase 1.2 选择器引擎 | ✅ 完成 | - | 6 种策略、指纹验证、Shadow DOM 支持 |
|
||||
| Phase 1.3 数据模型统一 | 🔄 进行中 | - | P0-P3 完成,P4 待实施 |
|
||||
| - M1 低风险接线 | ✅ 完成 | 2025-12 | StepRunner 依赖注入、tabId 管理 |
|
||||
| - M2 可控启用 hybrid | ✅ 完成 | 2025-12 | 执行模式配置、最小 allowlist |
|
||||
| - M2.1 双重策略修复 | ✅ 完成 | 2025-12 | skipRetry/skipNavWait 策略跳过 |
|
||||
| - P1.0 存储层统一 | ✅ 完成 | 2025-12 | ensureMigratedFromLocal、importFlowFromJson |
|
||||
| - M3-core 契约测试 | ✅ 完成 | 2025-12 | 42 个测试(adapter-policy + step-executor + session-dag-sync) |
|
||||
| - P2 录制链路迁移 | ✅ 完成 | 2025-12 | 增量式 DAG 同步、双写方案 |
|
||||
| - P4 清理旧类型 | ⏳ 待实施 | - | 删除 Step 联合类型、Flow.steps 字段 |
|
||||
| Phase 2-7 | ⏳ 待实施 | - | 录制系统、回放引擎、Builder、高级功能 |
|
||||
|
||||
**当前测试状态**: 197 个测试全部通过
|
||||
|
||||
---
|
||||
|
||||
## 下一步任务建议(供接手者参考)
|
||||
|
||||
### 优先级 1: M3-full 完整集成测试
|
||||
|
||||
验证 hybrid 模式下各类型行为一致性,特别关注:
|
||||
|
||||
- aria selector 定位
|
||||
- script when:'after' defer 执行时机
|
||||
- control-flow 条件求值
|
||||
- openTab/switchTab 后 ctx.tabId 更新
|
||||
|
||||
### 优先级 2: P4 清理旧类型
|
||||
|
||||
- 删除 `types.ts` 中的 `Step` 联合类型
|
||||
- 删除 `Flow.steps` 字段(或移至 `legacy-types.ts`)
|
||||
- 更新 UI Builder 保存格式
|
||||
|
||||
### 优先级 3: UI 刷新机制修复
|
||||
|
||||
IndexedDB 迁移后,popup/sidepanel 不再监听 chrome.storage.local 变化:
|
||||
|
||||
- 需要新的变更通知机制(可能通过 chrome.runtime.sendMessage)
|
||||
- 或改用 IndexedDB observer / BroadcastChannel
|
||||
|
||||
### 优先级 4: 录制期实时 DAG 展示(可选)
|
||||
|
||||
当前 DAG 只在内存态,可考虑:
|
||||
|
||||
- 将 nodes/edges 包含在 timeline 广播中
|
||||
- UI 端实时渲染 DAG 视图
|
||||
|
||||
---
|
||||
|
||||
## 实施进度
|
||||
|
||||
### 已完成
|
||||
@@ -50,12 +103,12 @@
|
||||
|
||||
#### Phase 1.3: 数据模型统一 🔄
|
||||
|
||||
**当前状态**:P0、P3 已完成。P1、P2、P4 待后续迭代。
|
||||
**当前状态**:P0、P1、P2、P3 已完成。P4 待后续迭代。
|
||||
|
||||
- P0 ✅:录制产物转换为 DAG,可直接回放
|
||||
- P1 ✅:存储层统一(ensureMigratedFromLocal、importFlowFromJson 多格式支持)
|
||||
- P2 ✅:录制链路迁移(增量式 DAG 同步,双写方案)
|
||||
- P3 ✅:22 个 Action Handlers 完整实现 + Scheduler 集成架构设计完成
|
||||
- P1 ⏳:存储层统一(IndexedDB schema、lazy normalize)
|
||||
- P2 ⏳:录制链路迁移到 Action
|
||||
- P4 ⏳:清理旧 Step 类型
|
||||
|
||||
**核心问题**:录制与回放数据格式不一致
|
||||
@@ -99,20 +152,139 @@
|
||||
- [x] 添加 `filterValidEdges` 校验旧 edges 有效性,避免 topoOrder 崩溃
|
||||
- 涉及文件:`packages/shared/src/rr-graph.ts`、`flow-store.ts`
|
||||
|
||||
**P1: 存储层统一(单一真源)**
|
||||
**P1: 存储层统一(单一真源)** ✅
|
||||
|
||||
- [x] `flow-store.ts` 读写逻辑适配新 Flow(P0 已完成)
|
||||
- [ ] `importFlowFromJson` 支持新旧格式自动识别(P0 已间接支持:导入后保存会触发 normalize)
|
||||
- [ ] 考虑 IndexedDB schema 升级策略,这里不用考虑,因为还没有任何人使用,没有任何数据产生,直接升级即可
|
||||
- [ ] 迁移场景:`ensureMigratedFromLocal()` 需要做 lazy normalize(当前迁移不走 saveFlow)
|
||||
- 涉及文件:`flow-store.ts`、`storage/indexeddb-manager.ts`
|
||||
- [x] `importFlowFromJson` 支持 4 种格式自动识别(数组、{flows:[]}、单 flow with steps、单 flow with nodes)
|
||||
- [x] `ensureMigratedFromLocal()` 调用已添加到所有存储入口点(listFlows, getFlow, saveFlow 等)
|
||||
- [x] `normalizeFlowForSave` 增加 edges 有效性校验(过滤指向不存在节点的边)
|
||||
- 涉及文件:`flow-store.ts`、`trigger-store.ts`
|
||||
|
||||
**P2: 录制链路迁移**
|
||||
**P2: 录制链路迁移 - 增量式 DAG 同步** ✅
|
||||
|
||||
- [ ] `flow-builder.ts` 改为写 `nodes: AnyAction[]`
|
||||
- [ ] `content-message-handler.ts` 接收 Step 后转换为 Action
|
||||
- [ ] 可选:修改 `recorder.js` 直接发送 Action
|
||||
- 涉及文件:`flow-builder.ts`、`content-message-handler.ts`、`session-manager.ts`
|
||||
采用"双写"方案:recorder.js 继续发送 Steps,background 在 `appendSteps` 时同步生成 nodes/edges。
|
||||
|
||||
- [x] `session-manager.ts:appendSteps` 增量生成 DAG
|
||||
- 新 step → 创建 node + edge(从前一个 node)
|
||||
- upsert step → 更新 node.config 和 node.type
|
||||
- 维护 session 级缓存:stepIndexMap、nodeIndexMap、edgeSeq
|
||||
- [x] 不变式检查:nodes.length === steps.length 且 edges.length === max(0, steps.length-1) 且 last edge → last step
|
||||
- [x] 违反不变式时 fallback 全量 `stepsToDAG` 重建
|
||||
- [x] 类型安全:unknown step type 降级到 'script' 并输出警告日志
|
||||
- [x] 契约测试:15 个测试覆盖 DAG 同步场景(`session-dag-sync.contract.test.ts`)
|
||||
- 涉及文件:`recording/session-manager.ts`
|
||||
|
||||
##### P2 详细实现说明
|
||||
|
||||
**核心改动位置**: `app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts`
|
||||
|
||||
**新增私有字段**:
|
||||
|
||||
```typescript
|
||||
// Session-level caches for incremental DAG sync (cleared on session start/stop)
|
||||
private stepIndexMap: Map<string, number> = new Map(); // stepId → 数组索引
|
||||
private nodeIndexMap: Map<string, number> = new Map(); // nodeId → 数组索引
|
||||
private edgeSeq: number = 0; // 单调递增的 edge id 序号
|
||||
```
|
||||
|
||||
**Session 生命周期管理**:
|
||||
|
||||
- `startSession()`: 清理所有缓存,调用 `rebuildCaches()` 初始化
|
||||
- `stopSession()`: 清理所有缓存
|
||||
|
||||
**增量 DAG 同步逻辑** (`appendSteps` 方法):
|
||||
|
||||
```typescript
|
||||
// 1. 初始化数组(如果缺失)
|
||||
if (!Array.isArray(f.steps)) f.steps = [];
|
||||
if (!Array.isArray(f.nodes)) f.nodes = [];
|
||||
if (!Array.isArray(f.edges)) f.edges = [];
|
||||
|
||||
// 2. 检查不变式,违反则 fallback 全量重建
|
||||
if (!this.checkDagInvariant(f.steps, nodes, edges)) {
|
||||
this.rebuildDag();
|
||||
}
|
||||
|
||||
// 3. 处理每个 step
|
||||
for (const step of steps) {
|
||||
if (this.stepIndexMap.has(step.id)) {
|
||||
// Upsert: 更新 node.config 和 node.type
|
||||
nodes[nodeIdx] = {
|
||||
...nodes[nodeIdx],
|
||||
type: this.toNodeType(step.type),
|
||||
config: mapStepToNodeConfig(step),
|
||||
};
|
||||
} else {
|
||||
// Append: 创建 node + edge
|
||||
nodes.push({
|
||||
id: step.id,
|
||||
type: this.toNodeType(step.type),
|
||||
config: mapStepToNodeConfig(step),
|
||||
});
|
||||
if (prevStepId) {
|
||||
edges.push({
|
||||
id: `e_${this.edgeSeq++}_${prevStepId}_${step.id}`,
|
||||
from: prevStepId,
|
||||
to: step.id,
|
||||
label: EDGE_LABELS.DEFAULT,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 最终不变式检查
|
||||
if (needsRebuild || !this.checkDagInvariant(f.steps, nodes, edges)) {
|
||||
this.rebuildDag();
|
||||
}
|
||||
```
|
||||
|
||||
**不变式检查** (`checkDagInvariant` 方法):
|
||||
|
||||
```typescript
|
||||
private checkDagInvariant(steps: Step[], nodes: NodeBase[], edges: Edge[]): boolean {
|
||||
const stepCount = steps.length;
|
||||
const expectedEdgeCount = Math.max(0, stepCount - 1);
|
||||
|
||||
// 1. nodes 数量必须等于 steps 数量
|
||||
if (nodes.length !== stepCount) return false;
|
||||
|
||||
// 2. edges 数量必须等于 steps.length - 1(线性链)
|
||||
if (edges.length !== expectedEdgeCount) return false;
|
||||
|
||||
// 3. 最后一条 edge 必须指向最后一个 step
|
||||
if (edges.length > 0 && steps.length > 0) {
|
||||
const lastEdge = edges[edges.length - 1];
|
||||
const lastStepId = steps[steps.length - 1]?.id;
|
||||
if (lastEdge.to !== lastStepId) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**类型安全** (`toNodeType` 方法):
|
||||
|
||||
```typescript
|
||||
private toNodeType(stepType: string): NodeBase['type'] {
|
||||
if (VALID_NODE_TYPES.has(stepType)) {
|
||||
return stepType as NodeBase['type'];
|
||||
}
|
||||
console.warn(`[RecordingSession] Unknown step type "${stepType}", falling back to "script"`);
|
||||
return NODE_TYPES.SCRIPT;
|
||||
}
|
||||
```
|
||||
|
||||
**测试覆盖** (`tests/record-replay/session-dag-sync.contract.test.ts`):
|
||||
|
||||
- 首个 step 创建 node(无 edge)
|
||||
- 后续 step 创建 node + edge
|
||||
- 批量 step 正确链接
|
||||
- upsert 更新 node config
|
||||
- upsert 保留 edges
|
||||
- 不变式处理(nodes 缺失、edges 缺失、edges 指向错误)
|
||||
- session 生命周期(start/stop 清理缓存)
|
||||
- 类型转换(有效类型、未知类型降级)
|
||||
- edge id 唯一性和单调序列
|
||||
|
||||
**P3: 回放引擎适配** ✅
|
||||
|
||||
@@ -280,26 +452,428 @@ export {
|
||||
} from './handlers';
|
||||
```
|
||||
|
||||
##### 后续接入步骤(未完成)
|
||||
##### 后续接入步骤
|
||||
|
||||
1. **修改 StepRunner 依赖注入 StepExecutorInterface**
|
||||
- 当前 `StepRunner` 直接调用 `executeStep`(`step-runner.ts:84`)
|
||||
- 需要改为通过 `StepExecutorInterface.execute()` 调用
|
||||
- 由 `Scheduler` 创建 `ActionRegistry` + `createExecutor` 并注入
|
||||
**M1: 低风险接线(已完成 ✅)**
|
||||
|
||||
2. **解决双重策略问题**
|
||||
- StepRunner 有 retry/timeout/nav-wait 策略(`step-runner.ts:82,106`)
|
||||
- ActionRegistry 也有 retry/timeout 策略(`registry.ts:462,527`)
|
||||
- 需明确唯一权威:使用 `skipActionsRetry/skipActionsNavWait` 配置控制
|
||||
1. ✅ **修改 StepRunner 依赖注入 StepExecutorInterface**
|
||||
- `StepRunner` 现在通过注入的 `StepExecutorInterface.execute()` 调用
|
||||
- `Scheduler` 创建 `createExecutor(config)` 并注入到 `StepRunner`
|
||||
- 默认使用 `legacy` 模式,保持原有行为不变
|
||||
|
||||
3. **tabId 管理**
|
||||
- 当前 ExecCtx 不携带 tabId
|
||||
- openTab/switchTab 后需要更新 tabId
|
||||
- 建议在 ExecCtx 中添加 `tabId` 字段并在 tab 切换时同步
|
||||
2. ✅ **tabId 管理**
|
||||
- `ExecCtx` 已添加 `tabId?: number` 字段
|
||||
- `Scheduler` 从 `ensureTab()` 捕获 tabId 并传入 `ExecCtx`
|
||||
- `StepRunner` 优先使用 `ctx.tabId`,fallback 到 active tab 查询
|
||||
|
||||
4. **集成测试**
|
||||
- 在 hybrid 模式下验证各类型行为一致性
|
||||
- 特别关注:aria selector、script when:'after' defer、control-flow 条件求值
|
||||
3. ✅ **双重策略问题(设计决策 + 实现)**
|
||||
- retry/nav-wait 策略:`StepRunner` 作为权威
|
||||
- `ExecutionModeConfig.skipActionsRetry/skipActionsNavWait` 默认为 true
|
||||
- 实现机制:
|
||||
- `adapter.ts`: `skipRetry=true` 时移除 `action.policy.retry`
|
||||
- `adapter.ts`: `skipNavWait=true` 时设置 `ctx.execution.skipNavWait`
|
||||
- `click.ts/navigate.ts`: 检查 `ctx.execution?.skipNavWait` 跳过内部 nav-wait
|
||||
- 注意:ActionRegistry timeout 保留(提供 per-action 超时保护)
|
||||
|
||||
##### M1 详细实现说明
|
||||
|
||||
**修改文件清单**:
|
||||
| 文件 | 改动内容 |
|
||||
|------|----------|
|
||||
| `nodes/types.ts` | `ExecCtx` 添加 `tabId?: number` 字段 |
|
||||
| `engine/runners/step-executor.ts` | 实现 `StepExecutorInterface`、`LegacyStepExecutor`、`ActionsStepExecutor`、`HybridStepExecutor`、`createExecutor()` 工厂 |
|
||||
| `engine/runners/step-runner.ts` | 构造函数接受 `StepExecutorInterface`,`executeNode()` 改为调用注入的执行器 |
|
||||
| `engine/scheduler.ts` | `runFlow()` 创建执行器并注入到 `StepRunner` |
|
||||
|
||||
**StepExecutorInterface 定义**:
|
||||
|
||||
```typescript
|
||||
export interface StepExecutionOptions {
|
||||
tabId: number;
|
||||
runId?: string;
|
||||
pushLog?: (entry: unknown) => void;
|
||||
}
|
||||
|
||||
export interface StepExecutionResult {
|
||||
executor: 'legacy' | 'actions';
|
||||
result: ExecResult;
|
||||
}
|
||||
|
||||
export interface StepExecutorInterface {
|
||||
execute(ctx: ExecCtx, step: Step, options: StepExecutionOptions): Promise<StepExecutionResult>;
|
||||
supports(stepType: string): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**执行器创建流程**:
|
||||
|
||||
```typescript
|
||||
// scheduler.ts
|
||||
const modeConfig = buildExecutionModeConfig(options);
|
||||
const registry = modeConfig.mode !== 'legacy' ? createReplayActionRegistry() : undefined;
|
||||
const stepExecutor = createExecutor(modeConfig, registry);
|
||||
const runner = new StepRunner(stepExecutor /* ... */);
|
||||
```
|
||||
|
||||
**M2: 可控启用 hybrid(已完成 ✅)**
|
||||
|
||||
1. ✅ **execution-mode.ts 新增最小 allowlist**
|
||||
- `MINIMAL_HYBRID_ACTION_TYPES`: fill/key/scroll/drag/wait/delay/screenshot/assert
|
||||
- 排除高风险类型(navigate/click/tab 管理)避免策略冲突
|
||||
- `createHybridConfig()` 默认使用最小 allowlist
|
||||
|
||||
2. ✅ **scheduler.ts 支持执行模式切换**
|
||||
- `RunOptions` 新增 `executionMode/actionsAllowlist/legacyOnlyTypes` 字段
|
||||
- `buildExecutionModeConfig()` 根据选项构建配置
|
||||
- 只在 hybrid/actions 模式下创建 `ActionRegistry`
|
||||
- 健壮性改进:只接受数组输入,防止误配置
|
||||
|
||||
3. ⏳ **openTab/switchTab 后同步更新 `ctx.tabId`**(M3 验证时完善)
|
||||
|
||||
**使用方式**:
|
||||
|
||||
```typescript
|
||||
// 默认 legacy(不传 executionMode)
|
||||
runFlow(flow, {});
|
||||
|
||||
// 启用 hybrid(最小 allowlist)
|
||||
runFlow(flow, { executionMode: 'hybrid' });
|
||||
|
||||
// 自定义 allowlist
|
||||
runFlow(flow, { executionMode: 'hybrid', actionsAllowlist: ['fill', 'key'] });
|
||||
|
||||
// 使用 MIGRATED_ACTION_TYPES(传空数组)
|
||||
runFlow(flow, { executionMode: 'hybrid', actionsAllowlist: [] });
|
||||
```
|
||||
|
||||
##### M2 详细实现说明
|
||||
|
||||
**修改文件清单**:
|
||||
| 文件 | 改动内容 |
|
||||
|------|----------|
|
||||
| `engine/execution-mode.ts` | 新增 `MINIMAL_HYBRID_ACTION_TYPES`、`createHybridConfig()`、`createActionsOnlyConfig()` |
|
||||
| `engine/scheduler.ts` | `RunOptions` 扩展、`buildExecutionModeConfig()` 实现 |
|
||||
|
||||
**MINIMAL_HYBRID_ACTION_TYPES 定义**:
|
||||
|
||||
```typescript
|
||||
export const MINIMAL_HYBRID_ACTION_TYPES = new Set<string>([
|
||||
'fill', // 低风险:表单填充
|
||||
'key', // 低风险:键盘输入
|
||||
'scroll', // 低风险:滚动
|
||||
'drag', // 低风险:拖拽
|
||||
'wait', // 低风险:等待条件
|
||||
'delay', // 低风险:延迟
|
||||
'screenshot', // 低风险:截图
|
||||
'assert', // 低风险:断言
|
||||
]);
|
||||
// 排除高风险:navigate(导航)、click(点击)、tab 管理
|
||||
```
|
||||
|
||||
**RunOptions 扩展**:
|
||||
|
||||
```typescript
|
||||
export interface RunOptions {
|
||||
// ... existing fields
|
||||
executionMode?: ExecutionMode; // 'legacy' | 'hybrid' | 'actions'
|
||||
actionsAllowlist?: string[]; // 允许使用 actions 的类型(hybrid 模式)
|
||||
legacyOnlyTypes?: string[]; // 强制使用 legacy 的类型
|
||||
}
|
||||
```
|
||||
|
||||
**buildExecutionModeConfig 实现**:
|
||||
|
||||
```typescript
|
||||
function buildExecutionModeConfig(options: RunOptions): ExecutionModeConfig {
|
||||
const mode = isExecutionMode(options.executionMode) ? options.executionMode : 'legacy';
|
||||
|
||||
if (mode === 'hybrid') {
|
||||
const overrides: Partial<ExecutionModeConfig> = {};
|
||||
if (Array.isArray(options.actionsAllowlist)) {
|
||||
overrides.actionsAllowlist = toStringSet(options.actionsAllowlist);
|
||||
}
|
||||
if (Array.isArray(options.legacyOnlyTypes)) {
|
||||
overrides.legacyOnlyTypes = toStringSet(options.legacyOnlyTypes);
|
||||
}
|
||||
return createHybridConfig(overrides);
|
||||
}
|
||||
|
||||
if (mode === 'actions') {
|
||||
return createActionsOnlyConfig();
|
||||
}
|
||||
|
||||
return { ...DEFAULT_EXECUTION_MODE_CONFIG };
|
||||
}
|
||||
```
|
||||
|
||||
**M2.1: 双重策略问题修复(已完成 ✅)**
|
||||
|
||||
**问题描述**: StepRunner 和 ActionRegistry 都有 retry/nav-wait 逻辑,会导致双重等待。
|
||||
|
||||
**解决方案**: StepRunner 作为策略权威,ActionRegistry 的内部策略可被跳过。
|
||||
|
||||
**修改文件清单**:
|
||||
| 文件 | 改动内容 |
|
||||
|------|----------|
|
||||
| `actions/types.ts` | 新增 `ExecutionFlags` 接口、`ActionExecutionContext.execution` 字段 |
|
||||
| `actions/adapter.ts` | `StepExecutorOptions` 新增 `skipRetry/skipNavWait`,实现策略跳过逻辑 |
|
||||
| `actions/handlers/click.ts` | 检查 `ctx.execution?.skipNavWait` 跳过导航等待 |
|
||||
| `actions/handlers/navigate.ts` | 检查 `ctx.execution?.skipNavWait` 跳过导航等待 |
|
||||
|
||||
**ExecutionFlags 接口**:
|
||||
|
||||
```typescript
|
||||
export interface ExecutionFlags {
|
||||
skipNavWait?: boolean; // 跳过 handler 内部的导航等待
|
||||
}
|
||||
|
||||
export interface ActionExecutionContext {
|
||||
// ... existing fields
|
||||
execution?: ExecutionFlags;
|
||||
}
|
||||
```
|
||||
|
||||
**adapter.ts 策略跳过逻辑**:
|
||||
|
||||
```typescript
|
||||
export interface StepExecutorOptions {
|
||||
runId?: string;
|
||||
pushLog?: (entry: unknown) => void;
|
||||
strict?: boolean;
|
||||
skipRetry?: boolean; // 移除 action.policy.retry
|
||||
skipNavWait?: boolean; // 设置 ctx.execution.skipNavWait
|
||||
}
|
||||
|
||||
// 在 createStepExecutor 中
|
||||
if (options?.skipRetry === true && action.policy?.retry) {
|
||||
action = { ...action, policy: { ...action.policy, retry: undefined } };
|
||||
}
|
||||
const execution: ExecutionFlags | undefined =
|
||||
options?.skipNavWait === true ? { skipNavWait: true } : undefined;
|
||||
```
|
||||
|
||||
**click.ts/navigate.ts 检查**:
|
||||
|
||||
```typescript
|
||||
const skipNavWait = ctx.execution?.skipNavWait === true;
|
||||
if (skipNavWait) {
|
||||
return { status: 'success' }; // 跳过导航等待
|
||||
}
|
||||
// ... 正常导航等待逻辑
|
||||
```
|
||||
|
||||
**P1.0: 存储层统一 - 迁移与导入(已完成 ✅)**
|
||||
|
||||
1. ✅ **启用 ensureMigratedFromLocal()**
|
||||
- `flow-store.ts`: 所有读写入口添加迁移 gate
|
||||
- `trigger-store.ts`: 所有读写入口添加迁移 gate
|
||||
- 迁移逻辑:从 chrome.storage.local 读取旧数据 → 写入 IndexedDB
|
||||
|
||||
2. ✅ **完善 importFlowFromJson()**
|
||||
- 支持 4 种格式:数组、{ flows }、单个 steps、单个 nodes-only
|
||||
- 更严格的字段验证(必须有 id)
|
||||
- 自动补齐 name/version/steps/meta 默认值
|
||||
|
||||
3. ✅ **edges 一致性校验**
|
||||
- `normalizeFlowForSave()` 在有 nodes 时也校验 edges
|
||||
- 移除引用不存在 node 的 edges,防止 scheduler 运行时错误
|
||||
|
||||
##### P1.0 详细实现说明
|
||||
|
||||
**修改文件清单**:
|
||||
| 文件 | 改动内容 |
|
||||
|------|----------|
|
||||
| `flow-store.ts` | 所有函数添加 `await ensureMigratedFromLocal()`;重写 `importFlowFromJson()` |
|
||||
| `trigger-store.ts` | 所有函数添加 `await ensureMigratedFromLocal()` |
|
||||
|
||||
**ensureMigratedFromLocal() 调用位置** (`flow-store.ts`):
|
||||
|
||||
```typescript
|
||||
export async function listFlows(): Promise<Flow[]> {
|
||||
await ensureMigratedFromLocal(); // ← 添加
|
||||
const flows = await IndexedDbStorage.flows.list();
|
||||
// ...
|
||||
}
|
||||
|
||||
export async function getFlow(flowId: string): Promise<Flow | undefined> {
|
||||
await ensureMigratedFromLocal(); // ← 添加
|
||||
// ...
|
||||
}
|
||||
|
||||
export async function saveFlow(flow: Flow): Promise<void> {
|
||||
await ensureMigratedFromLocal(); // ← 添加
|
||||
// ...
|
||||
}
|
||||
|
||||
// 同样: deleteFlow, listRuns, appendRun, listPublished, publishFlow, unpublishFlow,
|
||||
// exportFlow, exportAllFlows, importFlowFromJson, listSchedules, saveSchedule, removeSchedule
|
||||
```
|
||||
|
||||
**importFlowFromJson 重写**:
|
||||
|
||||
```typescript
|
||||
export async function importFlowFromJson(json: string): Promise<Flow[]> {
|
||||
await ensureMigratedFromLocal();
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
// 支持 4 种格式
|
||||
const candidates: unknown[] = Array.isArray(parsed)
|
||||
? parsed // 格式1: 数组
|
||||
: Array.isArray(parsed?.flows)
|
||||
? parsed.flows // 格式2: { flows: [...] }
|
||||
: parsed?.id && (Array.isArray(parsed?.steps) || Array.isArray(parsed?.nodes))
|
||||
? [parsed] // 格式3/4: 单个 flow (steps 或 nodes)
|
||||
: [];
|
||||
|
||||
if (!candidates.length) {
|
||||
throw new Error('invalid flow json: no flows found');
|
||||
}
|
||||
|
||||
// 验证和规范化每个 flow
|
||||
for (const raw of candidates) {
|
||||
const id = String(f.id || '').trim();
|
||||
if (!id) throw new Error('invalid flow json: missing id');
|
||||
|
||||
// 自动补齐字段
|
||||
const name = typeof f.name === 'string' && f.name.trim() ? f.name : id;
|
||||
const version = Number.isFinite(Number(f.version)) ? Number(f.version) : 1;
|
||||
const steps = Array.isArray(f.steps) ? f.steps : [];
|
||||
// ...
|
||||
}
|
||||
|
||||
// 保存(normalize on save)
|
||||
for (const f of flowsToImport) {
|
||||
await saveFlow(f);
|
||||
}
|
||||
|
||||
return flowsToImport;
|
||||
}
|
||||
```
|
||||
|
||||
**normalizeFlowForSave edges 校验** (`flow-store.ts:50`):
|
||||
|
||||
```typescript
|
||||
function normalizeFlowForSave(flow: Flow): Flow {
|
||||
const hasNodes = Array.isArray(flow.nodes) && flow.nodes.length > 0;
|
||||
if (hasNodes) {
|
||||
// 即使有 nodes,也校验 edges(处理导入/手动编辑的脏数据)
|
||||
const nodeIds = new Set(flow.nodes!.map((n) => n.id));
|
||||
if (Array.isArray(flow.edges) && flow.edges.length > 0) {
|
||||
const validEdges = filterValidEdges(flow.edges, nodeIds);
|
||||
if (validEdges.length !== flow.edges.length) {
|
||||
return { ...flow, edges: validEdges }; // 返回清理后的 flow
|
||||
}
|
||||
}
|
||||
return flow;
|
||||
}
|
||||
// ... 原有逻辑:从 steps 生成 nodes/edges
|
||||
}
|
||||
|
||||
function filterValidEdges(edges: Edge[], nodeIds: Set<string>): Edge[] {
|
||||
return edges.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to));
|
||||
}
|
||||
```
|
||||
|
||||
**M3-core: 契约测试(已完成 ✅)**
|
||||
|
||||
1. ✅ **测试基础设施**
|
||||
- `tests/record-replay/_test-helpers.ts`: 工厂函数和 mock helpers
|
||||
- 使用 vitest + mock,不依赖真实浏览器
|
||||
|
||||
2. ✅ **adapter-policy.contract.test.ts** (7 tests)
|
||||
- `skipRetry` 移除 `action.policy.retry` 验证
|
||||
- `skipNavWait` 设置 `ctx.execution.skipNavWait` 验证
|
||||
- 组合 flags 验证
|
||||
|
||||
3. ✅ **step-executor.contract.test.ts** (20 tests)
|
||||
- `DEFAULT_EXECUTION_MODE_CONFIG` 契约
|
||||
- `createHybridConfig` / `createActionsOnlyConfig` 契约
|
||||
- `LegacyStepExecutor` 行为验证
|
||||
- `HybridStepExecutor` 路由验证
|
||||
- `createExecutor` 工厂验证
|
||||
- `MINIMAL_HYBRID_ACTION_TYPES` 内容验证
|
||||
|
||||
4. ✅ **session-dag-sync.contract.test.ts** (15 tests)
|
||||
- 首个 step 创建 node(无 edge)
|
||||
- 后续 step 创建 node + edge
|
||||
- 批量 step 正确链接
|
||||
- upsert 更新 node config / 保留 edges
|
||||
- 不变式处理(nodes 缺失、edges 缺失、edges 指向错误)
|
||||
- session 生命周期(start/stop 清理缓存)
|
||||
- 类型转换(有效类型、未知类型降级)
|
||||
- edge id 唯一性和单调序列
|
||||
|
||||
##### M3-core 详细实现说明
|
||||
|
||||
**测试文件清单**:
|
||||
| 文件 | 测试数 | 覆盖内容 |
|
||||
|------|--------|----------|
|
||||
| `tests/record-replay/_test-helpers.ts` | - | 工厂函数:`createMockExecCtx`、`createMockActionCtx`、`createMockStep`、`createMockFlow`、`createMockRegistry` |
|
||||
| `tests/record-replay/adapter-policy.contract.test.ts` | 7 | adapter.ts 的 skipRetry/skipNavWait 策略跳过逻辑 |
|
||||
| `tests/record-replay/step-executor.contract.test.ts` | 20 | execution-mode.ts 配置契约、step-executor.ts 执行器路由 |
|
||||
| `tests/record-replay/session-dag-sync.contract.test.ts` | 15 | session-manager.ts 的增量 DAG 同步逻辑 |
|
||||
|
||||
**测试运行方式**:
|
||||
|
||||
```bash
|
||||
pnpm test # 运行所有测试
|
||||
pnpm test tests/record-replay/ # 运行 record-replay 相关测试
|
||||
```
|
||||
|
||||
**当前测试状态**: 197 个测试全部通过
|
||||
|
||||
**vitest mock 注意事项** (重要):
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:mock 函数定义在 vi.mock 外部会导致 hoisting 错误
|
||||
const mockFn = vi.fn();
|
||||
vi.mock('./module', () => ({ fn: mockFn }));
|
||||
|
||||
// ✅ 正确:mock 函数定义在 vi.mock 内部
|
||||
vi.mock('./module', () => ({
|
||||
fn: vi.fn(async () => ({ status: 'success' })),
|
||||
}));
|
||||
|
||||
// 获取 mock 引用
|
||||
import { fn } from './module';
|
||||
const mockFn = fn as ReturnType<typeof vi.fn>;
|
||||
```
|
||||
|
||||
**\_test-helpers.ts 工厂函数**:
|
||||
|
||||
```typescript
|
||||
// 创建最小 ExecCtx
|
||||
export function createMockExecCtx(overrides: Partial<ExecCtx> = {}): ExecCtx {
|
||||
return { vars: {}, logger: vi.fn(), ...overrides };
|
||||
}
|
||||
|
||||
// 创建最小 Step
|
||||
export function createMockStep(type: string, overrides: Record<string, unknown> = {}): any {
|
||||
return {
|
||||
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
type,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// 创建 mock ActionRegistry
|
||||
export function createMockRegistry(handlers: Map<string, any> = new Map()) {
|
||||
const executeFn = vi.fn(async () => ({ status: 'success' as const }));
|
||||
return {
|
||||
get: vi.fn((type: string) => handlers.get(type) || { type }),
|
||||
execute: executeFn,
|
||||
register: vi.fn(),
|
||||
has: vi.fn((type: string) => handlers.has(type)),
|
||||
_executeFn: executeFn, // 暴露给测试断言
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**M3-full: 完整集成测试(待实施)**
|
||||
|
||||
1. [ ] 在 hybrid 模式下验证各类型行为一致性
|
||||
2. [ ] 特别关注:aria selector、script when:'after' defer、control-flow 条件求值
|
||||
3. [ ] openTab/switchTab 后更新 ctx.tabId
|
||||
|
||||
**P4: 清理旧类型**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user