hangwin--mcp-chrome
680 行
22 KiB
TypeScript
680 行
22 KiB
TypeScript
/**
|
|
* Composable for managing Web Editor TX (Transaction) state in Sidepanel.
|
|
*
|
|
* Responsibilities:
|
|
* - Listen to WEB_EDITOR_TX_CHANGED messages from background
|
|
* - Persist and recover state from chrome.storage.session
|
|
* - Manage excluded element keys for selective Apply
|
|
* - Provide reactive state for AgentChat chips UI
|
|
*
|
|
* Architecture:
|
|
* - The composable should be initialized ONCE at the AgentChat.vue level
|
|
* - It is then provided via Vue's provide/inject to child components
|
|
* - This prevents duplicate event listener registration
|
|
*/
|
|
import { computed, onMounted, onUnmounted, ref, type InjectionKey } from 'vue';
|
|
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
|
|
import type {
|
|
ElementChangeSummary,
|
|
SelectedElementSummary,
|
|
WebEditorElementKey,
|
|
WebEditorSelectionChangedPayload,
|
|
WebEditorTxChangedPayload,
|
|
WebEditorTxChangeAction,
|
|
} from '@/common/web-editor-types';
|
|
|
|
// =============================================================================
|
|
// Constants
|
|
// =============================================================================
|
|
|
|
const WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-';
|
|
const WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX = 'web-editor-v2-excluded-keys-';
|
|
const WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX = 'web-editor-v2-selection-';
|
|
|
|
const VALID_TX_ACTIONS = new Set<WebEditorTxChangeAction>([
|
|
'push',
|
|
'merge',
|
|
'undo',
|
|
'redo',
|
|
'clear',
|
|
'rollback',
|
|
]);
|
|
|
|
// =============================================================================
|
|
// Internal Helpers
|
|
// =============================================================================
|
|
|
|
function isValidTabId(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
}
|
|
|
|
function buildTxSessionKey(tabId: number): string {
|
|
return `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${tabId}`;
|
|
}
|
|
|
|
function buildExcludedKeysSessionKey(tabId: number): string {
|
|
return `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${tabId}`;
|
|
}
|
|
|
|
function buildSelectionSessionKey(tabId: number): string {
|
|
return `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${tabId}`;
|
|
}
|
|
|
|
/**
|
|
* Normalize and validate selection changed payload from storage or message.
|
|
* Returns null if the payload is invalid.
|
|
*/
|
|
function normalizeSelectionPayload(raw: unknown): WebEditorSelectionChangedPayload | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
const tabId = Number(obj.tabId);
|
|
if (!Number.isFinite(tabId) || tabId <= 0) return null;
|
|
|
|
// Selected can be null (deselection) or an object
|
|
const selectedRaw = obj.selected;
|
|
let selected: SelectedElementSummary | null = null;
|
|
|
|
if (selectedRaw && typeof selectedRaw === 'object') {
|
|
const sel = selectedRaw as Record<string, unknown>;
|
|
const elementKey = typeof sel.elementKey === 'string' ? sel.elementKey.trim() : '';
|
|
if (!elementKey) return null; // Invalid selection
|
|
|
|
selected = {
|
|
elementKey,
|
|
locator: sel.locator as SelectedElementSummary['locator'],
|
|
label: typeof sel.label === 'string' ? sel.label : '',
|
|
fullLabel: typeof sel.fullLabel === 'string' ? sel.fullLabel : '',
|
|
tagName: typeof sel.tagName === 'string' ? sel.tagName : '',
|
|
updatedAt: typeof sel.updatedAt === 'number' ? sel.updatedAt : Date.now(),
|
|
};
|
|
}
|
|
|
|
return {
|
|
tabId,
|
|
selected,
|
|
pageUrl: typeof obj.pageUrl === 'string' ? obj.pageUrl : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize and validate TX changed payload from storage or message.
|
|
* Returns null if the payload is invalid.
|
|
*/
|
|
function normalizeTxChangedPayload(raw: unknown): WebEditorTxChangedPayload | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
const tabId = Number(obj.tabId);
|
|
if (!Number.isFinite(tabId) || tabId <= 0) return null;
|
|
|
|
const actionRaw = typeof obj.action === 'string' ? obj.action : '';
|
|
if (!VALID_TX_ACTIONS.has(actionRaw as WebEditorTxChangeAction)) return null;
|
|
const action = actionRaw as WebEditorTxChangeAction;
|
|
|
|
// Filter elements to ensure minimal validity (elementKey must be a non-empty string)
|
|
const rawElements = Array.isArray(obj.elements) ? obj.elements : [];
|
|
const elements = rawElements.filter(
|
|
(e): e is ElementChangeSummary =>
|
|
e &&
|
|
typeof e === 'object' &&
|
|
typeof (e as any).elementKey === 'string' &&
|
|
(e as any).elementKey,
|
|
);
|
|
|
|
const undoCountRaw = Number(obj.undoCount);
|
|
const redoCountRaw = Number(obj.redoCount);
|
|
const undoCount = Number.isFinite(undoCountRaw) && undoCountRaw >= 0 ? undoCountRaw : 0;
|
|
const redoCount = Number.isFinite(redoCountRaw) && redoCountRaw >= 0 ? redoCountRaw : 0;
|
|
|
|
const hasApplicableChanges = Boolean(obj.hasApplicableChanges);
|
|
const pageUrl = typeof obj.pageUrl === 'string' ? obj.pageUrl : undefined;
|
|
|
|
return {
|
|
tabId,
|
|
action,
|
|
elements,
|
|
undoCount,
|
|
redoCount,
|
|
hasApplicableChanges,
|
|
pageUrl,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize and deduplicate excluded keys array from storage.
|
|
* Filters out invalid entries and removes duplicates.
|
|
*/
|
|
function normalizeExcludedKeys(raw: unknown): WebEditorElementKey[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
|
|
const result: WebEditorElementKey[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const item of raw) {
|
|
const key = String(item ?? '').trim();
|
|
if (!key || seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(key);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Persist excluded keys to session storage (per-tab).
|
|
* Best-effort: silently ignores failures.
|
|
*/
|
|
async function persistExcludedKeys(
|
|
tabId: number,
|
|
keys: readonly WebEditorElementKey[],
|
|
): Promise<void> {
|
|
if (!isValidTabId(tabId)) return;
|
|
|
|
try {
|
|
if (typeof chrome === 'undefined' || !chrome.storage?.session?.set) return;
|
|
const storageKey = buildExcludedKeysSessionKey(tabId);
|
|
await chrome.storage.session.set({ [storageKey]: [...keys] });
|
|
} catch (error) {
|
|
console.error('[useWebEditorTxState] Failed to persist excluded keys:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default implementation for getting active tab ID.
|
|
*/
|
|
async function getActiveTabIdDefault(): Promise<number | null> {
|
|
try {
|
|
if (typeof chrome === 'undefined' || !chrome.tabs?.query) return null;
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
const tabId = tabs?.[0]?.id;
|
|
return typeof tabId === 'number' ? tabId : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current window ID for filtering tab activation events.
|
|
* This prevents processing tab switches from other Chrome windows.
|
|
*/
|
|
async function getCurrentWindowId(): Promise<number | null> {
|
|
try {
|
|
if (typeof chrome === 'undefined' || !chrome.windows?.getCurrent) return null;
|
|
const win = await chrome.windows.getCurrent();
|
|
return typeof win?.id === 'number' ? win.id : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Public API
|
|
// =============================================================================
|
|
|
|
export interface UseWebEditorTxStateOptions {
|
|
/**
|
|
* Optional override for resolving the "current tab" in sidepanel.
|
|
* Defaults to chrome.tabs.query({ active: true, currentWindow: true }).
|
|
*/
|
|
getActiveTabId?: () => Promise<number | null>;
|
|
/**
|
|
* If provided, skips querying the active tab on mount.
|
|
*/
|
|
initialTabId?: number | null;
|
|
}
|
|
|
|
export function useWebEditorTxState(options: UseWebEditorTxStateOptions = {}) {
|
|
// ==========================================================================
|
|
// State
|
|
// ==========================================================================
|
|
|
|
/** Current tab ID being tracked */
|
|
const tabId = ref<number | null>(
|
|
isValidTabId(options.initialTabId) ? options.initialTabId : null,
|
|
);
|
|
|
|
/** Current TX state from web-editor */
|
|
const txState = ref<WebEditorTxChangedPayload | null>(null);
|
|
|
|
/** Currently selected element (for context, may not have edits) */
|
|
const selectedElement = ref<SelectedElementSummary | null>(null);
|
|
|
|
/** Page URL from selection (may differ from txState.pageUrl if selection is newer) */
|
|
const selectionPageUrl = ref<string | null>(null);
|
|
|
|
/** Excluded element keys (user-deselected elements) */
|
|
const excludedKeys = ref<WebEditorElementKey[]>([]);
|
|
|
|
// ==========================================================================
|
|
// Computed
|
|
// ==========================================================================
|
|
|
|
/** All elements from TX state */
|
|
const allElements = computed<ElementChangeSummary[]>(() => txState.value?.elements ?? []);
|
|
|
|
/** Set of excluded keys for O(1) lookup */
|
|
const excludedKeySet = computed(() => new Set(excludedKeys.value));
|
|
|
|
/** Elements that will be applied (not excluded) */
|
|
const applicableElements = computed<ElementChangeSummary[]>(() => {
|
|
const set = excludedKeySet.value;
|
|
return allElements.value.filter((e) => !set.has(e.elementKey));
|
|
});
|
|
|
|
/** Elements that are excluded by user */
|
|
const excludedElements = computed<ElementChangeSummary[]>(() => {
|
|
const set = excludedKeySet.value;
|
|
return allElements.value.filter((e) => set.has(e.elementKey));
|
|
});
|
|
|
|
/** Whether there are applicable changes to send to Agent */
|
|
const hasChanges = computed<boolean>(() => applicableElements.value.length > 0);
|
|
|
|
/** Whether there is a selected element */
|
|
const hasSelection = computed<boolean>(() => selectedElement.value !== null);
|
|
|
|
/**
|
|
* Whether the selected element is also in the edits list.
|
|
* Used to decide if we need a separate "selection-only" chip.
|
|
*/
|
|
const isSelectionInEdits = computed<boolean>(() => {
|
|
const sel = selectedElement.value;
|
|
if (!sel) return false;
|
|
return allElements.value.some((e) => e.elementKey === sel.elementKey);
|
|
});
|
|
|
|
/** Whether to show the web editor section (has edits OR has selection) */
|
|
const hasContent = computed<boolean>(
|
|
() => hasChanges.value || hasSelection.value || allElements.value.length > 0,
|
|
);
|
|
|
|
// ==========================================================================
|
|
// Actions
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Toggle an element's excluded state.
|
|
* Automatically persists to session storage.
|
|
*/
|
|
function toggleExclude(elementKey: WebEditorElementKey): void {
|
|
const key = String(elementKey ?? '').trim();
|
|
if (!key) return;
|
|
|
|
const current = excludedKeys.value;
|
|
const idx = current.indexOf(key);
|
|
if (idx >= 0) {
|
|
// Remove from excluded list
|
|
excludedKeys.value = [...current.slice(0, idx), ...current.slice(idx + 1)];
|
|
} else {
|
|
// Add to excluded list
|
|
excludedKeys.value = [...current, key];
|
|
}
|
|
|
|
// Persist to session storage
|
|
if (isValidTabId(tabId.value)) {
|
|
void persistExcludedKeys(tabId.value, excludedKeys.value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all excluded elements.
|
|
* Automatically persists to session storage.
|
|
*/
|
|
function clearExcluded(): void {
|
|
excludedKeys.value = [];
|
|
|
|
// Persist to session storage
|
|
if (isValidTabId(tabId.value)) {
|
|
void persistExcludedKeys(tabId.value, excludedKeys.value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove excluded keys that no longer exist in the current TX state.
|
|
* This prevents stale keys when elements are undone/cleared.
|
|
*/
|
|
function pruneStaleExcludedKeys(elements: readonly ElementChangeSummary[] | null): void {
|
|
if (!elements || !isValidTabId(tabId.value)) return;
|
|
|
|
const validKeys = new Set(elements.map((e) => e.elementKey));
|
|
const prunedKeys = excludedKeys.value.filter((k) => validKeys.has(k));
|
|
|
|
// Only update if there are stale keys to remove
|
|
if (prunedKeys.length === excludedKeys.value.length) return;
|
|
|
|
excludedKeys.value = prunedKeys;
|
|
void persistExcludedKeys(tabId.value, prunedKeys);
|
|
}
|
|
|
|
/** Sequence counter to prevent stale async updates */
|
|
let refreshSeq = 0;
|
|
|
|
/**
|
|
* Refresh TX state from session storage for a specific tab.
|
|
* Also restores excluded keys from storage.
|
|
* On tab change, immediately clears state to prevent cross-tab pollution.
|
|
*/
|
|
async function refreshFromStorage(targetTabId: number): Promise<void> {
|
|
if (!isValidTabId(targetTabId)) {
|
|
tabId.value = null;
|
|
txState.value = null;
|
|
excludedKeys.value = [];
|
|
selectedElement.value = null;
|
|
selectionPageUrl.value = null;
|
|
return;
|
|
}
|
|
|
|
// On tab change, immediately clear state to prevent UI showing stale data
|
|
const isTabChange = tabId.value !== targetTabId;
|
|
if (isTabChange) {
|
|
txState.value = null;
|
|
excludedKeys.value = [];
|
|
selectedElement.value = null;
|
|
selectionPageUrl.value = null;
|
|
}
|
|
tabId.value = targetTabId;
|
|
|
|
const seq = ++refreshSeq;
|
|
const txKey = buildTxSessionKey(targetTabId);
|
|
const excludedKey = buildExcludedKeysSessionKey(targetTabId);
|
|
const selectionKey = buildSelectionSessionKey(targetTabId);
|
|
|
|
try {
|
|
if (typeof chrome === 'undefined' || !chrome.storage?.session?.get) {
|
|
txState.value = null;
|
|
excludedKeys.value = [];
|
|
selectedElement.value = null;
|
|
selectionPageUrl.value = null;
|
|
return;
|
|
}
|
|
|
|
// Fetch TX state, excluded keys, and selection in one call
|
|
const result = (await chrome.storage.session.get([
|
|
txKey,
|
|
excludedKey,
|
|
selectionKey,
|
|
])) as Record<string, unknown>;
|
|
|
|
// Check for stale async response
|
|
if (seq !== refreshSeq) return;
|
|
|
|
// Update TX state
|
|
const nextTxState = normalizeTxChangedPayload(result?.[txKey]);
|
|
txState.value = nextTxState;
|
|
|
|
// Restore excluded keys from storage
|
|
excludedKeys.value = normalizeExcludedKeys(result?.[excludedKey]);
|
|
|
|
// Restore selection from storage
|
|
const nextSelection = normalizeSelectionPayload(result?.[selectionKey]);
|
|
selectedElement.value = nextSelection?.selected ?? null;
|
|
selectionPageUrl.value = nextSelection?.pageUrl ?? null;
|
|
|
|
// Prune stale excluded keys based on current elements
|
|
pruneStaleExcludedKeys(nextTxState?.elements ?? null);
|
|
} catch (error) {
|
|
console.error('[useWebEditorTxState] Failed to refresh from session storage:', error);
|
|
// On error, ensure clean state to prevent showing stale data
|
|
txState.value = null;
|
|
excludedKeys.value = [];
|
|
selectedElement.value = null;
|
|
selectionPageUrl.value = null;
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Message Listeners
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Handle runtime messages from background.
|
|
*/
|
|
const onRuntimeMessage = (
|
|
message: unknown,
|
|
_sender: chrome.runtime.MessageSender,
|
|
_sendResponse: (response?: unknown) => void,
|
|
): void => {
|
|
const msg =
|
|
message && typeof message === 'object' ? (message as Record<string, unknown>) : null;
|
|
if (!msg) return;
|
|
|
|
// Handle TX changed messages
|
|
if (msg.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED) {
|
|
const next = normalizeTxChangedPayload(msg.payload);
|
|
if (!next) return;
|
|
|
|
// Only process messages for the current tab
|
|
if (!isValidTabId(tabId.value)) return;
|
|
if (next.tabId !== tabId.value) return;
|
|
|
|
txState.value = next;
|
|
|
|
// Prune excluded keys that no longer exist (e.g., after undo/clear)
|
|
pruneStaleExcludedKeys(next.elements);
|
|
return;
|
|
}
|
|
|
|
// Handle selection changed messages
|
|
if (msg.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED) {
|
|
const next = normalizeSelectionPayload(msg.payload);
|
|
if (!next) return;
|
|
|
|
// Only process messages for the current tab
|
|
if (!isValidTabId(tabId.value)) return;
|
|
if (next.tabId !== tabId.value) return;
|
|
|
|
selectedElement.value = next.selected;
|
|
// Store pageUrl from selection for context building
|
|
selectionPageUrl.value = next.pageUrl ?? null;
|
|
return;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle session storage changes (fallback for cold start).
|
|
* Only handles TX state changes; excluded keys are managed explicitly.
|
|
*/
|
|
const onSessionChanged = (changes: { [key: string]: chrome.storage.StorageChange }): void => {
|
|
if (!isValidTabId(tabId.value)) return;
|
|
const txKey = buildTxSessionKey(tabId.value);
|
|
|
|
const change = changes?.[txKey];
|
|
if (!change) return;
|
|
|
|
if (change.newValue === undefined) {
|
|
txState.value = null;
|
|
// Clear excluded keys when TX state is cleared
|
|
pruneStaleExcludedKeys([]);
|
|
return;
|
|
}
|
|
|
|
const next = normalizeTxChangedPayload(change.newValue);
|
|
txState.value = next;
|
|
|
|
// Prune stale excluded keys
|
|
pruneStaleExcludedKeys(next?.elements ?? []);
|
|
};
|
|
|
|
/** Cleanup function for storage listener */
|
|
let removeStorageListener: (() => void) | null = null;
|
|
|
|
/** Cleanup function for tab activated listener */
|
|
let removeTabActivatedListener: (() => void) | null = null;
|
|
|
|
/** Cached window ID to filter tab activation events from other windows */
|
|
let currentWindowId: number | null = null;
|
|
|
|
/**
|
|
* Handle tab activation events.
|
|
* Updates tabId and loads TX state when user switches to a different tab.
|
|
*
|
|
* Note: currentWindowId filtering is best-effort. If getCurrentWindowId() fails,
|
|
* events from all windows will be processed (acceptable fallback behavior).
|
|
*/
|
|
const onTabActivated = (activeInfo: chrome.tabs.TabActiveInfo): void => {
|
|
try {
|
|
// Ignore events from other windows (best-effort filter)
|
|
if (currentWindowId !== null && activeInfo.windowId !== currentWindowId) return;
|
|
|
|
const nextTabId = activeInfo.tabId;
|
|
if (!isValidTabId(nextTabId)) return;
|
|
|
|
// Skip if already tracking this tab
|
|
if (nextTabId === tabId.value) return;
|
|
|
|
// Load TX state for the newly activated tab
|
|
void refreshFromStorage(nextTabId);
|
|
} catch (error) {
|
|
console.error('[useWebEditorTxState] Failed to handle tab activation:', error);
|
|
}
|
|
};
|
|
|
|
// ==========================================================================
|
|
// Lifecycle
|
|
// ==========================================================================
|
|
|
|
onMounted(async () => {
|
|
// Register runtime message listener
|
|
try {
|
|
if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage?.addListener) {
|
|
chrome.runtime.onMessage.addListener(onRuntimeMessage);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to register WebEditor TX runtime listener:', error);
|
|
}
|
|
|
|
// Register session storage listener
|
|
try {
|
|
if (typeof chrome !== 'undefined' && chrome.storage?.session?.onChanged?.addListener) {
|
|
// Prefer session-specific listener if available
|
|
chrome.storage.session.onChanged.addListener(onSessionChanged);
|
|
removeStorageListener = () => {
|
|
try {
|
|
chrome.storage.session.onChanged.removeListener(onSessionChanged);
|
|
} catch {}
|
|
};
|
|
} else if (typeof chrome !== 'undefined' && chrome.storage?.onChanged?.addListener) {
|
|
// Fallback to generic storage listener with area filter
|
|
const onChanged = (
|
|
changes: { [key: string]: chrome.storage.StorageChange },
|
|
areaName: chrome.storage.AreaName,
|
|
) => {
|
|
if (areaName !== 'session') return;
|
|
onSessionChanged(changes);
|
|
};
|
|
|
|
chrome.storage.onChanged.addListener(onChanged);
|
|
removeStorageListener = () => {
|
|
try {
|
|
chrome.storage.onChanged.removeListener(onChanged);
|
|
} catch {}
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to register WebEditor TX storage listener:', error);
|
|
}
|
|
|
|
// Cache current window ID for filtering tab activation events
|
|
currentWindowId = await getCurrentWindowId();
|
|
|
|
// Register tab activation listener to track tab switches
|
|
try {
|
|
if (typeof chrome !== 'undefined' && chrome.tabs?.onActivated?.addListener) {
|
|
chrome.tabs.onActivated.addListener(onTabActivated);
|
|
removeTabActivatedListener = () => {
|
|
try {
|
|
chrome.tabs.onActivated.removeListener(onTabActivated);
|
|
} catch {}
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('[useWebEditorTxState] Failed to register tab activation listener:', error);
|
|
}
|
|
|
|
// Initialize tab ID if not provided
|
|
const getActiveTabId = options.getActiveTabId ?? getActiveTabIdDefault;
|
|
|
|
if (!isValidTabId(tabId.value)) {
|
|
const active = await getActiveTabId().catch(() => null);
|
|
if (isValidTabId(active)) {
|
|
tabId.value = active;
|
|
}
|
|
}
|
|
|
|
// Load initial state from storage
|
|
if (isValidTabId(tabId.value)) {
|
|
await refreshFromStorage(tabId.value);
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
// Clean up runtime message listener
|
|
try {
|
|
if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage?.removeListener) {
|
|
chrome.runtime.onMessage.removeListener(onRuntimeMessage);
|
|
}
|
|
} catch {}
|
|
|
|
// Clean up storage listener
|
|
removeStorageListener?.();
|
|
removeStorageListener = null;
|
|
|
|
// Clean up tab activation listener
|
|
removeTabActivatedListener?.();
|
|
removeTabActivatedListener = null;
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Return
|
|
// ==========================================================================
|
|
|
|
return {
|
|
// State
|
|
tabId,
|
|
txState,
|
|
excludedKeys,
|
|
selectedElement,
|
|
selectionPageUrl,
|
|
|
|
// UI State (computed)
|
|
allElements,
|
|
hasChanges,
|
|
hasSelection,
|
|
isSelectionInEdits,
|
|
hasContent,
|
|
applicableElements,
|
|
excludedElements,
|
|
|
|
// Actions
|
|
toggleExclude,
|
|
clearExcluded,
|
|
refreshFromStorage,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Type Exports & Injection Key
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Return type of useWebEditorTxState composable.
|
|
* Used for type-safe provide/inject.
|
|
*/
|
|
export type WebEditorTxStateReturn = ReturnType<typeof useWebEditorTxState>;
|
|
|
|
/**
|
|
* Injection key for providing WebEditorTxState to child components.
|
|
* Use this with Vue's provide/inject pattern to avoid duplicate listener registration.
|
|
*
|
|
* @example
|
|
* // In AgentChat.vue (parent)
|
|
* const webEditorTx = useWebEditorTxState();
|
|
* provide(WEB_EDITOR_TX_STATE_INJECTION_KEY, webEditorTx);
|
|
*
|
|
* // In WebEditorChanges.vue (child)
|
|
* const tx = inject(WEB_EDITOR_TX_STATE_INJECTION_KEY);
|
|
*/
|
|
export const WEB_EDITOR_TX_STATE_INJECTION_KEY: InjectionKey<WebEditorTxStateReturn> =
|
|
Symbol('web-editor-tx-state');
|