hangwin--mcp-chrome
387 行
11 KiB
TypeScript
387 行
11 KiB
TypeScript
/**
|
|
* Quick Panel Shadow Host
|
|
*
|
|
* Creates an isolated Shadow DOM container for the Quick Panel AI Chat UI.
|
|
* This module runs in a content script context and provides:
|
|
*
|
|
* - Style isolation via Shadow DOM (no CSS bleed in/out)
|
|
* - Event isolation (UI events don't bubble to the host page)
|
|
* - Theme synchronization with AgentChat (via chrome.storage)
|
|
*
|
|
* Architecture:
|
|
* - Host element attached to documentElement with highest z-index
|
|
* - Shadow root contains styles + UI container
|
|
* - Theme is synced from chrome.storage.local['agentTheme']
|
|
*/
|
|
|
|
import { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';
|
|
import { QUICK_PANEL_STYLES } from './styles';
|
|
|
|
// ============================================================
|
|
// Types
|
|
// ============================================================
|
|
|
|
/**
|
|
* Elements exposed by the shadow host for UI mounting.
|
|
*/
|
|
export interface QuickPanelShadowHostElements {
|
|
/** The host element attached to the document */
|
|
host: HTMLElement;
|
|
/** The shadow root */
|
|
shadowRoot: ShadowRoot;
|
|
/** Container for UI elements (pointer-events: none by default) */
|
|
uiRoot: HTMLElement;
|
|
/** Theme root element (class="agent-theme qp-root") */
|
|
root: HTMLElement;
|
|
}
|
|
|
|
/**
|
|
* Manager interface for the shadow host.
|
|
*/
|
|
export interface QuickPanelShadowHostManager {
|
|
/** Get the current elements (null if disposed) */
|
|
getElements: () => QuickPanelShadowHostElements | null;
|
|
/** Check if a node belongs to this shadow host */
|
|
isOverlayElement: (node: unknown) => boolean;
|
|
/** Check if an event originated from within the shadow host */
|
|
isEventFromUi: (event: Event) => boolean;
|
|
/** Clean up and remove the shadow host */
|
|
dispose: () => void;
|
|
}
|
|
|
|
/**
|
|
* Options for mounting the shadow host.
|
|
*/
|
|
export interface QuickPanelShadowHostOptions {
|
|
/** Custom host element ID (default: __mcp_quick_panel_host__) */
|
|
hostId?: string;
|
|
/** Custom z-index (default: 2147483647 - highest possible) */
|
|
zIndex?: number;
|
|
}
|
|
|
|
// ============================================================
|
|
// Constants
|
|
// ============================================================
|
|
|
|
const DEFAULT_HOST_ID = '__mcp_quick_panel_host__';
|
|
const UI_CONTAINER_ID = '__mcp_quick_panel_ui__';
|
|
const ROOT_ID = '__mcp_quick_panel_root__';
|
|
|
|
/** Highest possible z-index to ensure Quick Panel is on top */
|
|
const DEFAULT_Z_INDEX = 2147483647;
|
|
|
|
/** Storage key for AgentChat theme (owned by sidepanel) */
|
|
const THEME_STORAGE_KEY = 'agentTheme';
|
|
|
|
/** Default theme if none is set */
|
|
const DEFAULT_THEME_ID = 'warm-editorial';
|
|
|
|
/** Dark theme ID for dark mode */
|
|
const DARK_THEME_ID = 'dark-console';
|
|
|
|
/** Valid theme IDs (subset supported by Quick Panel) */
|
|
const VALID_THEME_IDS = new Set([
|
|
'warm-editorial',
|
|
'blueprint-architect',
|
|
'zen-journal',
|
|
'neo-pop',
|
|
'dark-console',
|
|
'swiss-grid',
|
|
]);
|
|
|
|
/** Light theme IDs that should switch to dark in dark mode */
|
|
const LIGHT_THEME_IDS = new Set([
|
|
'warm-editorial',
|
|
'blueprint-architect',
|
|
'zen-journal',
|
|
'neo-pop',
|
|
'swiss-grid',
|
|
]);
|
|
|
|
/** Events to stop from propagating to the host page */
|
|
const BLOCKED_EVENT_TYPES = [
|
|
// Pointer events
|
|
'pointerdown',
|
|
'pointerup',
|
|
'pointermove',
|
|
'pointerenter',
|
|
'pointerleave',
|
|
'pointercancel',
|
|
// Mouse events
|
|
'mousedown',
|
|
'mouseup',
|
|
'mousemove',
|
|
'mouseenter',
|
|
'mouseleave',
|
|
'click',
|
|
'dblclick',
|
|
'contextmenu',
|
|
// Keyboard events
|
|
'keydown',
|
|
'keyup',
|
|
'keypress',
|
|
// Touch events
|
|
'touchstart',
|
|
'touchmove',
|
|
'touchend',
|
|
'touchcancel',
|
|
// Scroll events
|
|
'wheel',
|
|
// Form events
|
|
'focus',
|
|
'blur',
|
|
'input',
|
|
'change',
|
|
] as const;
|
|
|
|
// ============================================================
|
|
// Utility Functions
|
|
// ============================================================
|
|
|
|
/**
|
|
* Set a CSS property with !important to override page styles.
|
|
*/
|
|
function setImportantStyle(element: HTMLElement, property: string, value: string): void {
|
|
element.style.setProperty(property, value, 'important');
|
|
}
|
|
|
|
/**
|
|
* Normalize and validate a theme ID.
|
|
*/
|
|
function normalizeThemeId(value: unknown): string {
|
|
if (typeof value !== 'string') return DEFAULT_THEME_ID;
|
|
const trimmed = value.trim();
|
|
return VALID_THEME_IDS.has(trimmed) ? trimmed : DEFAULT_THEME_ID;
|
|
}
|
|
|
|
/**
|
|
* Check if system prefers dark mode.
|
|
*/
|
|
function systemPrefersDark(): boolean {
|
|
try {
|
|
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get effective theme ID considering system dark mode preference.
|
|
* If system is in dark mode and the theme is a light theme, switch to dark-console.
|
|
*/
|
|
function getEffectiveThemeId(baseThemeId: string): string {
|
|
if (systemPrefersDark() && LIGHT_THEME_IDS.has(baseThemeId)) {
|
|
return DARK_THEME_ID;
|
|
}
|
|
return baseThemeId;
|
|
}
|
|
|
|
/**
|
|
* Read the stored theme ID from chrome.storage.
|
|
*/
|
|
async function readStoredThemeId(): Promise<string> {
|
|
try {
|
|
if (!chrome?.storage?.local) return DEFAULT_THEME_ID;
|
|
const result = await chrome.storage.local.get(THEME_STORAGE_KEY);
|
|
return normalizeThemeId(result[THEME_STORAGE_KEY]);
|
|
} catch {
|
|
return DEFAULT_THEME_ID;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply a theme ID to the root element, considering system dark mode preference.
|
|
*/
|
|
function applyThemeId(root: HTMLElement, themeId: string): void {
|
|
const normalizedTheme = normalizeThemeId(themeId);
|
|
const effectiveTheme = getEffectiveThemeId(normalizedTheme);
|
|
root.dataset.agentTheme = effectiveTheme;
|
|
}
|
|
|
|
// ============================================================
|
|
// Main Export
|
|
// ============================================================
|
|
|
|
/**
|
|
* Mount the Quick Panel Shadow DOM host.
|
|
*
|
|
* @param options - Configuration options
|
|
* @returns Manager interface for the shadow host
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const shadowHost = mountQuickPanelShadowHost();
|
|
* const elements = shadowHost.getElements();
|
|
*
|
|
* if (elements) {
|
|
* // Mount UI into elements.root
|
|
* mountQuickPanelAiChatPanel({
|
|
* mount: elements.root,
|
|
* agentBridge,
|
|
* });
|
|
* }
|
|
*
|
|
* // Cleanup when done
|
|
* shadowHost.dispose();
|
|
* ```
|
|
*/
|
|
export function mountQuickPanelShadowHost(
|
|
options: QuickPanelShadowHostOptions = {},
|
|
): QuickPanelShadowHostManager {
|
|
const disposer = new Disposer();
|
|
let elements: QuickPanelShadowHostElements | null = null;
|
|
|
|
const hostId = options.hostId ?? DEFAULT_HOST_ID;
|
|
const zIndex = options.zIndex ?? DEFAULT_Z_INDEX;
|
|
|
|
// Clean up any existing host (from previous instance or crash recovery)
|
|
const existing = document.getElementById(hostId);
|
|
if (existing) {
|
|
try {
|
|
existing.remove();
|
|
} catch {
|
|
// Best-effort cleanup
|
|
}
|
|
}
|
|
|
|
// Create host element
|
|
const host = document.createElement('div');
|
|
host.id = hostId;
|
|
host.setAttribute('data-mcp-quick-panel', 'true');
|
|
|
|
// Apply styles with !important to override page styles
|
|
setImportantStyle(host, 'position', 'fixed');
|
|
setImportantStyle(host, 'inset', '0');
|
|
setImportantStyle(host, 'z-index', String(zIndex));
|
|
setImportantStyle(host, 'pointer-events', 'none');
|
|
setImportantStyle(host, 'contain', 'layout style paint');
|
|
setImportantStyle(host, 'isolation', 'isolate');
|
|
|
|
// Create shadow root
|
|
const shadowRoot = host.attachShadow({ mode: 'open' });
|
|
|
|
// Inject styles
|
|
const styleEl = document.createElement('style');
|
|
styleEl.textContent = QUICK_PANEL_STYLES;
|
|
shadowRoot.append(styleEl);
|
|
|
|
// Create UI container
|
|
const uiRoot = document.createElement('div');
|
|
uiRoot.id = UI_CONTAINER_ID;
|
|
setImportantStyle(uiRoot, 'position', 'fixed');
|
|
setImportantStyle(uiRoot, 'inset', '0');
|
|
setImportantStyle(uiRoot, 'pointer-events', 'none');
|
|
shadowRoot.append(uiRoot);
|
|
|
|
// Create theme root (where UI components mount)
|
|
const root = document.createElement('div');
|
|
root.id = ROOT_ID;
|
|
root.className = 'agent-theme qp-root';
|
|
|
|
// Apply theme synchronously BEFORE mounting to avoid flash
|
|
// Use system dark mode preference as initial hint
|
|
const initialTheme = getEffectiveThemeId(DEFAULT_THEME_ID);
|
|
root.dataset.agentTheme = initialTheme;
|
|
|
|
uiRoot.append(root);
|
|
|
|
// Mount to document
|
|
const mountPoint = document.documentElement ?? document.body;
|
|
mountPoint.append(host);
|
|
disposer.add(() => host.remove());
|
|
|
|
elements = { host, shadowRoot, uiRoot, root };
|
|
|
|
// Event isolation: stop UI events from bubbling to the page
|
|
const stopPropagation = (event: Event): void => {
|
|
event.stopPropagation();
|
|
};
|
|
|
|
for (const eventType of BLOCKED_EVENT_TYPES) {
|
|
disposer.listen(root, eventType, stopPropagation);
|
|
}
|
|
|
|
// Async update with stored theme (if different from initial)
|
|
void (async () => {
|
|
const themeId = await readStoredThemeId();
|
|
applyThemeId(root, themeId);
|
|
})();
|
|
|
|
// System dark mode change listener
|
|
// Re-apply theme when system color scheme changes
|
|
let currentStoredThemeId = DEFAULT_THEME_ID;
|
|
|
|
// Track the stored theme ID
|
|
void (async () => {
|
|
currentStoredThemeId = await readStoredThemeId();
|
|
})();
|
|
|
|
// Theme change listener
|
|
const handleStorageChange = (
|
|
changes: Record<string, chrome.storage.StorageChange>,
|
|
areaName: string,
|
|
): void => {
|
|
if (areaName !== 'local') return;
|
|
const change = changes[THEME_STORAGE_KEY];
|
|
if (!change) return;
|
|
// Update tracked theme ID and apply
|
|
currentStoredThemeId = normalizeThemeId(change.newValue);
|
|
applyThemeId(root, currentStoredThemeId);
|
|
};
|
|
|
|
try {
|
|
chrome?.storage?.onChanged?.addListener(handleStorageChange);
|
|
disposer.add(() => chrome?.storage?.onChanged?.removeListener(handleStorageChange));
|
|
} catch {
|
|
// Best-effort: theme sync is optional
|
|
}
|
|
|
|
try {
|
|
const darkModeMediaQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
|
|
if (darkModeMediaQuery) {
|
|
const handleDarkModeChange = (): void => {
|
|
applyThemeId(root, currentStoredThemeId);
|
|
};
|
|
|
|
// Use addEventListener for modern browsers
|
|
if (typeof darkModeMediaQuery.addEventListener === 'function') {
|
|
darkModeMediaQuery.addEventListener('change', handleDarkModeChange);
|
|
disposer.add(() => darkModeMediaQuery.removeEventListener('change', handleDarkModeChange));
|
|
}
|
|
}
|
|
} catch {
|
|
// Best-effort: dark mode detection is optional
|
|
}
|
|
|
|
// Helper to check if a node belongs to this shadow host
|
|
const isOverlayElement = (node: unknown): boolean => {
|
|
if (!(node instanceof Node)) return false;
|
|
if (node === host) return true;
|
|
|
|
const rootNode = typeof node.getRootNode === 'function' ? node.getRootNode() : null;
|
|
return rootNode instanceof ShadowRoot && rootNode.host === host;
|
|
};
|
|
|
|
// Helper to check if an event originated from within the shadow host
|
|
const isEventFromUi = (event: Event): boolean => {
|
|
try {
|
|
if (typeof event.composedPath === 'function') {
|
|
return event.composedPath().some((el) => isOverlayElement(el));
|
|
}
|
|
} catch {
|
|
// Fallback to checking target
|
|
}
|
|
return isOverlayElement(event.target);
|
|
};
|
|
|
|
return {
|
|
getElements: () => elements,
|
|
isOverlayElement,
|
|
isEventFromUi,
|
|
dispose: () => {
|
|
elements = null;
|
|
disposer.dispose();
|
|
},
|
|
};
|
|
}
|