"添加前端模板和运行代码模块"

This commit is contained in:
Codex
2026-06-01 13:43:09 +08:00
parent 24045274ad
commit 8092c4b1f8
587 changed files with 99761 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
# Vue3 Design Mode Composables
Vue3 Composition API equivalents of the React design mode hooks.
## Overview
This directory contains Vue3 composables that provide the same functionality as the React hooks in `/src/client/`. These composables enable design mode functionality in Vue3 applications.
## Composables
### 1. `useDesignMode.ts` (912 lines)
**Purpose**: Global design mode state management and bridge communication.
**Ported from**: `src/client/DesignModeContext.tsx` (1,079 lines)
**Key Features**:
- Global state management using `reactive()` and `provide/inject`
- Bridge communication with parent window via postMessage
- Message handling for style/content updates
- Batch update support with debouncing
- Health check and connection monitoring
**Usage**:
```typescript
import { createDesignMode, useDesignMode } from './composables';
// In root component
const designMode = createDesignMode({
enabled: true,
iframeMode: {
enabled: true,
enableSelection: true,
enableDirectEdit: true,
},
batchUpdate: {
enabled: true,
debounceMs: 300,
},
});
// In child components
const {
isDesignMode,
selectedElement,
toggleDesignMode,
selectElement,
modifyElementClass,
updateElementContent
} = useDesignMode();
```
**Key Methods**:
- `toggleDesignMode()` - Toggle design mode on/off
- `selectElement(element)` - Select an element for editing
- `modifyElementClass(element, newClass)` - Update element classes
- `updateElementContent(element, newContent)` - Update element text content
- `batchUpdateElements(updates)` - Batch multiple updates
- `resetModifications()` - Reload page to reset changes
### 2. `useSelectionManager.ts` (539 lines)
**Purpose**: Element selection and interaction handling.
**Ported from**: `src/client/SelectionManager.tsx` (597 lines)
**Key Features**:
- Click and hover event handling
- Element validation (must have source mapping + static-content/static-class)
- Visual highlighting with outline and shadow
- Keyboard shortcuts (Esc to clear, Ctrl/Cmd+A to select)
- Element info extraction with hierarchy
- Component root detection for library components
**Usage**:
```typescript
import { useSelectionManager } from './composables';
const {
selectedElement,
hoverElement,
selectElement,
clearSelection,
extractElementInfo,
addSelectionListener,
} = useSelectionManager(document.body, {
enableSelection: true,
enableHover: true,
selectionDelay: 0,
excludeSelectors: ['script', 'style', 'meta'],
includeOnlyElements: false,
});
// Listen to selection changes
const unsubscribe = addSelectionListener((element) => {
if (element) {
const info = extractElementInfo(element);
console.log('Selected:', info);
}
});
```
**Key Methods**:
- `selectElement(element)` - Select and highlight element
- `clearSelection()` - Clear current selection
- `extractElementInfo(element)` - Get detailed element information
- `addSelectionListener(callback)` - Subscribe to selection changes
### 3. `useEditManager.ts` (367 lines)
**Purpose**: Content editing with contentEditable mode.
**Ported from**: `src/client/managers/EditManager.ts` (365 lines)
**Key Features**:
- ContentEditable mode for inline text editing
- Real-time sync to related elements (list items with same element-id)
- Throttled notifications (300ms) during typing
- Enter saves, Escape cancels
- Click outside to save
- Automatic peer element synchronization
**Usage**:
```typescript
import { useEditManager } from './composables';
const processUpdate = async (update: UpdateState) => {
// Handle update logic
return { success: true };
};
const {
handleDirectEdit,
editTextContent,
updateContent,
updateStyle,
updateAttribute,
} = useEditManager(processUpdate, config);
// Double-click to edit
element.addEventListener('dblclick', () => {
handleDirectEdit(element, 'content');
});
```
**Key Methods**:
- `handleDirectEdit(element, type)` - Trigger edit mode
- `editTextContent(element)` - Enable contentEditable editing
- `updateContent(element, newValue)` - Programmatic content update
- `updateStyle(element, newClass)` - Programmatic style update
- `updateAttribute(element, name, value)` - Update element attribute
### 4. `useObserverManager.ts` (98 lines)
**Purpose**: DOM mutation observation.
**Ported from**: `src/client/managers/ObserverManager.ts` (81 lines)
**Key Features**:
- MutationObserver setup for DOM changes
- Watches: childList, characterData, attributes (class, style)
- Skips elements with `data-ignore-mutation` attribute
- Callbacks for content/style edits and node additions
**Usage**:
```typescript
import { useObserverManager } from './composables';
const { enable, disable, isEnabled } = useObserverManager(
(target, type) => {
console.log(`Edit detected: ${type} on`, target);
},
(node) => {
console.log('Node added:', node);
}
);
// Start observing
enable();
// Stop observing
disable();
```
**Key Methods**:
- `enable()` - Start observing DOM mutations
- `disable()` - Stop observing and disconnect
## Key Differences from React Version
### State Management
- **React**: `useState`, `useCallback`, `useEffect`
- **Vue3**: `ref`, `reactive`, `computed`, `watch`, `onMounted`, `onBeforeUnmount`
### Context/Injection
- **React**: `createContext`, `useContext`, `Provider`
- **Vue3**: `provide`, `inject`, `InjectionKey`
### Lifecycle
- **React**: `useEffect` with cleanup
- **Vue3**: `onMounted`, `onBeforeUnmount`
### Reactivity
- **React**: Explicit state setters
- **Vue3**: Direct mutation of `ref.value` or `reactive` properties
## Integration with Shared Code
All composables import shared utilities from `/src/client-shared/`:
- `bridge.ts` - PostMessage communication
- `attributeNames.ts` - Data attribute name resolution
- `sourceInfo.ts` - Source mapping extraction
- `sourceInfoResolver.ts` - Usage-site resolution
- `elementUtils.ts` - Element validation helpers
## Type Safety
All composables are fully typed with TypeScript, importing types from:
- `/src/types/messages.ts` - Message type definitions
- `/src/types/UpdateTypes.ts` - Update operation types
## Testing
To test these composables:
1. Import in a Vue3 component
2. Call `createDesignMode()` in the root component
3. Use `useDesignMode()` in child components
4. Verify bridge communication with parent window
5. Test element selection and editing
## Future Enhancements
- [ ] Add Vue3-specific optimizations (e.g., `shallowRef` for large objects)
- [ ] Create Vue3 component wrappers for easier integration
- [ ] Add Pinia store integration option
- [ ] Implement Vue DevTools integration
- [ ] Add unit tests with Vitest

View File

@@ -0,0 +1,16 @@
/**
* Vue3 Composables for Design Mode
*
* These composables provide Vue3 equivalents to the React hooks
* for managing design mode functionality.
*/
export { createDesignMode, useDesignMode } from './useDesignMode';
export type { DesignModeConfig, Modification } from './useDesignMode';
export { useSelectionManager } from './useSelectionManager';
export type { SelectionManagerConfig } from './useSelectionManager';
export { useEditManager } from './useEditManager';
export { useObserverManager } from './useObserverManager';

View File

@@ -0,0 +1,915 @@
import { ref, reactive, computed, provide, inject, onMounted, onBeforeUnmount } from 'vue';
import { twMerge } from 'tailwind-merge';
import type {
DesignModeMessage,
ParentToIframeMessage,
IframeToParentMessage,
BridgeConfig,
ElementInfo,
SourceInfo,
ToggleDesignModeMessage,
UpdateStyleMessage,
UpdateContentMessage,
BatchUpdateMessage,
HeartbeatMessage,
HealthCheckMessage,
HealthCheckResponseMessage,
} from '@xagi/design-mode-shared/messages';
import { bridge, messageValidator } from '@xagi/design-mode-shared/bridge';
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
import { extractSourceInfo as extractSourceInfoFromAttributes } from '@xagi/design-mode-shared/sourceInfo';
import { resolveSourceInfo } from '@xagi/design-mode-shared/sourceInfoResolver';
export interface Modification {
id: string;
element: string;
type: 'class' | 'style';
oldValue: string;
newValue: string;
timestamp: number;
}
export interface DesignModeConfig {
enabled?: boolean;
iframeMode?: {
enabled: boolean;
hideUI: boolean;
enableSelection: boolean;
enableDirectEdit: boolean;
};
batchUpdate?: {
enabled: boolean;
debounceMs: number;
};
bridge?: Partial<BridgeConfig>;
}
interface DesignModeState {
isDesignMode: boolean;
selectedElement: HTMLElement | null;
modifications: Modification[];
isConnected: boolean;
bridgeStatus: 'connected' | 'disconnected' | 'connecting' | 'error';
config: DesignModeConfig;
}
const DESIGN_MODE_KEY = Symbol('design-mode');
/**
* Vue3 composable for design mode management
* Provides global state and actions for design mode functionality
*/
export function createDesignMode(userConfig: DesignModeConfig = {}) {
// Defaults
const defaultConfig: DesignModeConfig = {
enabled: true,
iframeMode: {
enabled: true,
hideUI: false,
enableSelection: true,
enableDirectEdit: true,
},
batchUpdate: {
enabled: true,
debounceMs: 300,
},
bridge: {
timeout: 10000,
retryAttempts: 3,
heartbeatInterval: 30000,
debug: process.env.NODE_ENV === 'development',
},
...userConfig,
};
// State
const state = reactive<DesignModeState>({
isDesignMode: false,
selectedElement: null,
modifications: [],
isConnected: false,
bridgeStatus: 'connecting',
config: defaultConfig,
});
// Batch debounce state
const batchUpdateTimer = ref<NodeJS.Timeout | null>(null);
const pendingBatchUpdates = ref<
Array<{
element: HTMLElement;
type: 'style' | 'content';
newValue: string;
originalValue?: string;
}>
>([]);
// Unsubscribe handlers
const unsubscribeHandlers = ref<(() => void)[]>([]);
/**
* postMessage when iframe + connected
*/
const sendToParent = (message: IframeToParentMessage) => {
if (!state.config.iframeMode?.enabled) {
return;
}
// Use bridge if connected, fallback to direct postMessage
if (bridge.isConnected()) {
bridge.send(message).catch(error => {
if (window.self !== window.top) {
window.parent.postMessage(message, '*');
}
});
return;
}
if (window.self !== window.top) {
window.parent.postMessage(message, '*');
}
};
/**
* findElementBySourceInfo
*/
const findElementBySourceInfo = (sourceInfo: SourceInfo): HTMLElement | null => {
// 1) element-id
if (sourceInfo.elementId) {
const element = document.querySelector(`[${AttributeNames.elementId}="${sourceInfo.elementId}"]`);
if (element) return element as HTMLElement;
}
// 2) legacy file/line/column attrs
const selector = `[${AttributeNames.file}="${sourceInfo.fileName}"][${AttributeNames.line}="${sourceInfo.lineNumber}"][${AttributeNames.column}="${sourceInfo.columnNumber}"]`;
const element = document.querySelector(selector);
if (element) return element as HTMLElement;
// 3) children-source
const childrenSourceValue = `${sourceInfo.fileName}:${sourceInfo.lineNumber}:${sourceInfo.columnNumber}`;
const elementByChildrenSource = document.querySelector(`[${AttributeNames.childrenSource}="${childrenSourceValue}"]`);
if (elementByChildrenSource) return elementByChildrenSource as HTMLElement;
// 4) scan all -info
const allElements = document.querySelectorAll(`[${AttributeNames.info}]`);
for (let i = 0; i < allElements.length; i++) {
const el = allElements[i] as HTMLElement;
try {
const infoStr = el.getAttribute(AttributeNames.info);
if (infoStr) {
const info = JSON.parse(infoStr);
if (
info.fileName === sourceInfo.fileName &&
info.lineNumber === sourceInfo.lineNumber &&
info.columnNumber === sourceInfo.columnNumber
) {
return el;
}
}
} catch (e) {
// ignore parse error
}
}
return null;
};
/**
* Parent-driven style patch
*/
const handleExternalStyleUpdate = async (message: UpdateStyleMessage) => {
if (!state.config.iframeMode?.enabled) return;
const updateMessage = message;
const { sourceInfo, newClass } = updateMessage.payload;
try {
// validate
const validation = messageValidator.validate(updateMessage);
if (!validation.isValid) {
console.error(
'[DesignMode] Invalid style update message:',
validation.errors
);
return;
}
// resolve element
const element = findElementBySourceInfo(sourceInfo);
if (!element) {
console.error(
'[DesignMode] Element not found for sourceInfo:',
sourceInfo
);
sendToParent({
type: 'ERROR',
payload: {
code: 'ELEMENT_NOT_FOUND',
message: `Element not found: ${sourceInfo.fileName}:${sourceInfo.lineNumber}:${sourceInfo.columnNumber}`,
details: { sourceInfo },
},
timestamp: Date.now(),
});
return;
}
const oldClass = element.className;
// Same element-id → list sync
const elementId = element.getAttribute(AttributeNames.elementId);
let relatedElements: HTMLElement[] = [element];
if (elementId) {
// query all [element-id]
const allElementsWithId = Array.from(
document.querySelectorAll(`[${AttributeNames.elementId}]`)
) as HTMLElement[];
relatedElements = allElementsWithId.filter(el => {
const elId = el.getAttribute(AttributeNames.elementId);
return elId === elementId;
});
} else {
console.warn('[DesignMode] Element missing element-id attribute, only updating current element');
}
// Apply class to all peers
relatedElements.forEach(el => {
el.setAttribute('data-ignore-mutation', 'true');
el.className = newClass;
// Use setTimeout to ensure MutationObserver sees the attribute
setTimeout(() => {
el.removeAttribute('data-ignore-mutation');
}, 0);
});
// Refresh selection ref
if (state.selectedElement === element) {
state.selectedElement = element;
}
// STYLE_UPDATED
sendToParent({
type: 'STYLE_UPDATED',
payload: {
sourceInfo,
oldClass,
newClass,
},
timestamp: Date.now(),
});
} catch (error) {
console.error(
'[DesignMode] Error handling external style update:',
error
);
// ERROR
sendToParent({
type: 'ERROR',
payload: {
code: 'STYLE_UPDATE_FAILED',
message: error instanceof Error ? error.message : 'Unknown error',
details: { sourceInfo: updateMessage.payload.sourceInfo },
},
timestamp: Date.now(),
});
}
};
/**
* Parent-driven content patch
*/
const handleExternalContentUpdate = async (message: UpdateContentMessage) => {
if (!state.config.iframeMode?.enabled) return;
const updateMessage = message;
const { sourceInfo, newContent } = updateMessage.payload;
try {
// validate
const validation = messageValidator.validate(updateMessage);
if (!validation.isValid) {
console.error(
'[DesignMode] Invalid content update message:',
validation.errors
);
return;
}
// resolve element
const element = findElementBySourceInfo(sourceInfo);
if (!element) {
console.error(
'[DesignMode] Element not found for sourceInfo:',
sourceInfo
);
sendToParent({
type: 'ERROR',
payload: {
code: 'ELEMENT_NOT_FOUND',
message: `Element not found: ${sourceInfo.fileName}:${sourceInfo.lineNumber}:${sourceInfo.columnNumber}`,
details: { sourceInfo },
},
timestamp: Date.now(),
});
return;
}
const originalContent = element.innerText || element.textContent || '';
// Same element-id → list sync
const elementId = element.getAttribute(AttributeNames.elementId);
let relatedElements: HTMLElement[] = [element];
if (elementId) {
// query all [element-id]
const allElementsWithId = Array.from(
document.querySelectorAll(`[${AttributeNames.elementId}]`)
) as HTMLElement[];
relatedElements = allElementsWithId.filter(el => {
const elId = el.getAttribute(AttributeNames.elementId);
return elId === elementId;
});
} else {
console.warn('[DesignMode] Element missing element-id attribute, only updating current element');
}
// Apply text to all peers (list sync)
relatedElements.forEach(el => {
el.setAttribute('data-ignore-mutation', 'true');
el.innerText = newContent;
// Use setTimeout to ensure MutationObserver sees the attribute
setTimeout(() => {
el.removeAttribute('data-ignore-mutation');
}, 0);
});
// Refresh selection ref
if (state.selectedElement === element) {
state.selectedElement = element;
}
// CONTENT_UPDATED_CALLBACK
sendToParent({
type: 'CONTENT_UPDATED_CALLBACK',
payload: {
sourceInfo,
oldValue: originalContent,
newValue: newContent,
},
timestamp: Date.now(),
});
} catch (error) {
console.error(
'[DesignMode] Error handling external content update:',
error
);
// ERROR
sendToParent({
type: 'ERROR',
payload: {
code: 'CONTENT_UPDATE_FAILED',
message: error instanceof Error ? error.message : 'Unknown error',
details: { sourceInfo: updateMessage.payload.sourceInfo },
},
timestamp: Date.now(),
});
}
};
/**
* updateSource (HTTP)
*/
const updateSource = async (
element: HTMLElement,
newValue: string,
type: 'style' | 'content',
originalValue?: string
) => {
const sourceInfo = extractSourceInfo(element);
if (!sourceInfo) {
throw new Error('Element does not have source mapping data');
}
try {
const response = await fetch('/__appdev_design_mode/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filePath: sourceInfo.fileName,
line: sourceInfo.lineNumber,
column: sourceInfo.columnNumber,
newValue,
type,
originalValue,
}),
});
if (!response.ok) {
throw new Error('Failed to update source');
}
} catch (error) {
console.error('[DesignMode] Error updating source:', error);
throw error;
}
};
/**
* Parent BATCH_UPDATE
*/
const handleExternalBatchUpdate = async (message: BatchUpdateMessage) => {
const updateMessage = message;
const { updates } = updateMessage.payload;
try {
// validate
const validation = messageValidator.validate(updateMessage);
if (!validation.isValid) {
console.error(
'[DesignMode] Invalid batch update message:',
validation.errors
);
return;
}
// Promise.allSettled items
const results = await Promise.allSettled(
updates.map(async (update: any) => {
const element = findElementBySourceInfo(update.sourceInfo);
if (!element) {
throw new Error(
`Element not found: ${update.sourceInfo.fileName}:${update.sourceInfo.lineNumber}`
);
}
if (update.type === 'style') {
element.setAttribute('data-ignore-mutation', 'true');
element.className = update.newValue;
setTimeout(() => element.removeAttribute('data-ignore-mutation'), 0);
} else if (update.type === 'content') {
element.setAttribute('data-ignore-mutation', 'true');
element.innerText = update.newValue;
setTimeout(() => element.removeAttribute('data-ignore-mutation'), 0);
}
await updateSource(
element,
update.newValue,
update.type,
update.originalValue
);
return { success: true, sourceInfo: update.sourceInfo };
})
);
} catch (error) {
console.error('[DesignMode] Error handling batch update:', error);
// ERROR
sendToParent({
type: 'ERROR',
payload: {
code: 'BATCH_UPDATE_FAILED',
message: error instanceof Error ? error.message : 'Unknown error',
details: { updatesCount: updates?.length || 0 },
},
timestamp: Date.now(),
});
}
};
/**
* 悬停高亮由 DesignModeApp 通过 data-design-hover 等在 DOM 上处理;
* 保留空实现供根组件调用,避免运行时 TypeError后续可接入状态或埋点。
*/
const setHoveredElement = (_element: HTMLElement | null) => {};
/**
* selectElement
*/
const selectElement = async (element: HTMLElement | null) => {
if (element && (element.hasAttribute(AttributeNames.staticContent)
|| element.hasAttribute(AttributeNames.staticClass))) {
state.selectedElement = element;
} else {
state.selectedElement = null;
}
// iframe: ELEMENT_SELECTED
if (element && state.config.iframeMode?.enabled) {
const sourceInfo = resolveSourceInfo(element);
if (sourceInfo) {
const hasStaticContentAttr = element.hasAttribute(AttributeNames.staticContent);
const isActuallyPureText = isPureStaticText(element);
const isStaticText = hasStaticContentAttr && isActuallyPureText;
const isStaticClass = element.hasAttribute(AttributeNames.staticClass);
let textContent = '';
if (isStaticText) {
textContent = element.textContent || element.innerText || '';
} else {
textContent = element.innerText || element.textContent || '';
}
const elementInfo: ElementInfo = {
tagName: element.tagName.toLowerCase(),
className: element.className,
textContent: textContent,
sourceInfo,
isStaticText: isStaticText || false,
isStaticClass: isStaticClass,
};
sendToParent({
type: 'ELEMENT_SELECTED',
payload: { elementInfo },
timestamp: Date.now(),
});
} else {
console.warn(
`[DesignMode] Element selected but could not resolve source info:`,
element
);
}
} else if (!element && state.config.iframeMode?.enabled) {
sendToParent({
type: 'ELEMENT_DESELECTED',
timestamp: Date.now(),
});
}
};
/**
* toggleDesignMode
*/
const toggleDesignMode = () => {
state.isDesignMode = !state.isDesignMode;
if (!state.isDesignMode) {
state.selectedElement = null;
}
};
/**
* Local extractSourceInfo helper
*/
const extractSourceInfo = (element: HTMLElement): SourceInfo | null => {
return extractSourceInfoFromAttributes(element);
};
/**
* modifyElementClass
*/
const modifyElementClass = async (element: HTMLElement, newClass: string) => {
const oldClasses = element.className;
const mergedClasses = twMerge(oldClasses, newClass);
// twMerge + DOM
element.className = mergedClasses;
// PATCH /update
await updateSource(element, mergedClasses, 'style', oldClasses);
// modifications list
const modification: Modification = {
id: Date.now().toString(),
element: element.id || 'unknown',
type: 'class',
oldValue: oldClasses,
newValue: mergedClasses,
timestamp: Date.now(),
};
state.modifications = [modification, ...state.modifications];
// STYLE_UPDATED to parent
if (state.config.iframeMode?.enabled) {
const sourceInfo = extractSourceInfo(element);
if (sourceInfo) {
sendToParent({
type: 'STYLE_UPDATED',
payload: {
sourceInfo,
oldClass: oldClasses,
newClass: mergedClasses,
},
timestamp: Date.now(),
});
}
}
};
/**
* updateElementContent
*/
const updateElementContent = async (element: HTMLElement, newContent: string) => {
const sourceInfo = extractSourceInfo(element);
const originalContent = element.innerText;
// Update DOM
element.innerText = newContent;
// PATCH /update
await updateSource(element, newContent, 'content', originalContent);
// CONTENT_UPDATED to parent
if (state.config.iframeMode?.enabled) {
const sourceInfo = extractSourceInfo(element);
if (sourceInfo) {
sendToParent({
type: 'CONTENT_UPDATED',
payload: {
sourceInfo,
oldValue: originalContent,
newValue: newContent,
},
timestamp: Date.now(),
});
}
}
};
/**
* batchUpdateElements
*/
const batchUpdateElements = async (
updates: Array<{
element: HTMLElement;
type: 'style' | 'content';
newValue: string;
originalValue?: string;
}>
) => {
if (!state.config.batchUpdate?.enabled) {
// Sequential fallback
await Promise.all(
updates.map(update => {
if (update.type === 'style') {
return modifyElementClass(update.element, update.newValue);
} else {
return updateElementContent(update.element, update.newValue);
}
})
);
return;
}
// Debounced queue
const newUpdates = [...pendingBatchUpdates.value, ...updates];
pendingBatchUpdates.value = newUpdates;
// Reset debounce timer
if (batchUpdateTimer.value) {
clearTimeout(batchUpdateTimer.value);
}
// Schedule flush
const timer = setTimeout(async () => {
try {
// Build payload
const batchUpdateItems = newUpdates.map(update => {
const sourceInfo = extractSourceInfo(update.element);
if (!sourceInfo) {
throw new Error('Element missing source mapping');
}
return {
type: update.type,
sourceInfo,
newValue: update.newValue,
originalValue: update.originalValue,
};
});
// iframe: bridge BATCH_UPDATE
if (state.config.iframeMode?.enabled) {
await bridge.send({
type: 'BATCH_UPDATE',
payload: { updates: batchUpdateItems },
timestamp: Date.now(),
});
} else {
// top: fetch batch endpoint
await fetch('/__appdev_design_mode/batch-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
updates: batchUpdateItems,
}),
});
}
// Clear queue
pendingBatchUpdates.value = [];
} catch (error) {
console.error('[DesignMode] Batch update failed:', error);
pendingBatchUpdates.value = [];
throw error;
}
}, state.config.batchUpdate.debounceMs);
batchUpdateTimer.value = timer;
};
/**
* resetModifications
*/
const resetModifications = () => {
window.location.reload();
};
/**
* sendMessage
*/
const sendMessage = async <T extends DesignModeMessage>(message: T) => {
if (state.config.iframeMode?.enabled) {
await bridge.send(message);
}
};
/**
* sendMessageWithResponse
*/
const sendMessageWithResponse = async <T extends DesignModeMessage, R extends DesignModeMessage>(
message: T,
responseType: R['type']
): Promise<R> => {
if (state.config.iframeMode?.enabled) {
return await bridge.sendWithResponse(message, responseType);
}
throw new Error('Iframe mode is not enabled');
};
/**
* healthCheck
*/
const healthCheck = async () => {
if (state.config.iframeMode?.enabled) {
return await bridge.healthCheck();
}
return { status: 'healthy', details: { mode: 'standalone' } };
};
/**
* Wire iframe bridge listeners
*/
const setupBridge = () => {
if (state.config.iframeMode?.enabled) {
state.bridgeStatus = 'connecting';
// Poll isConnected
const connectionCheck = setInterval(() => {
const connected = bridge.isConnected();
state.isConnected = connected;
state.bridgeStatus = connected ? 'connected' : 'disconnected';
}, 1000);
// TOGGLE_DESIGN_MODE
unsubscribeHandlers.value.push(
bridge.on<ToggleDesignModeMessage>('TOGGLE_DESIGN_MODE', message => {
const newState = message.enabled;
state.isDesignMode = newState;
// Echo DESIGN_MODE_CHANGED
if (window.self !== window.top) {
sendToParent({
type: 'DESIGN_MODE_CHANGED',
enabled: newState,
requestId: message.requestId,
timestamp: Date.now(),
});
}
})
);
// UPDATE_STYLE
unsubscribeHandlers.value.push(
bridge.on<UpdateStyleMessage>('UPDATE_STYLE', async message => {
await handleExternalStyleUpdate(message);
})
);
// UPDATE_CONTENT
unsubscribeHandlers.value.push(
bridge.on<UpdateContentMessage>('UPDATE_CONTENT', async message => {
await handleExternalContentUpdate(message);
})
);
// BATCH_UPDATE
unsubscribeHandlers.value.push(
bridge.on<BatchUpdateMessage>('BATCH_UPDATE', async message => {
await handleExternalBatchUpdate(message);
})
);
// HEARTBEAT
unsubscribeHandlers.value.push(
bridge.on<HeartbeatMessage>('HEARTBEAT', _ => {
// Echo HEARTBEAT
bridge.send({
type: 'HEARTBEAT',
payload: { timestamp: Date.now() },
timestamp: Date.now(),
});
})
);
// HEALTH_CHECK
unsubscribeHandlers.value.push(
bridge.on<HealthCheckMessage>('HEALTH_CHECK', async message => {
const healthStatus = await bridge.healthCheck();
const response: HealthCheckResponseMessage = {
type: 'HEALTH_CHECK_RESPONSE',
payload: {
status: healthStatus.status === 'healthy' ? 'healthy' : 'unhealthy',
version: '2.0.0',
uptime: Date.now() - ((window as any).__startTime || 0),
},
requestId: message.requestId || '',
timestamp: Date.now(),
};
bridge.send(response);
})
);
// Initial health probe
const initialHealthCheck = setTimeout(async () => {
try {
const health = await bridge.healthCheck();
state.bridgeStatus = health.status === 'healthy' ? 'connected' : 'error';
} catch (error) {
state.bridgeStatus = 'error';
}
}, 1000);
// Cleanup function
return () => {
clearInterval(connectionCheck);
clearTimeout(initialHealthCheck);
unsubscribeHandlers.value.forEach(unsubscribe => unsubscribe());
};
}
};
onMounted(() => {
const cleanup = setupBridge();
if (cleanup) {
onBeforeUnmount(cleanup);
}
});
const api = {
// State
state,
isDesignMode: computed(() => state.isDesignMode),
selectedElement: computed(() => state.selectedElement),
modifications: computed(() => state.modifications),
isConnected: computed(() => state.isConnected),
bridgeStatus: computed(() => state.bridgeStatus),
config: computed(() => state.config),
// Actions
toggleDesignMode,
setHoveredElement,
selectElement,
modifyElementClass,
updateElementContent,
batchUpdateElements,
resetModifications,
// Bridge helpers
sendMessage,
sendMessageWithResponse,
healthCheck,
};
// Provide for injection
provide(DESIGN_MODE_KEY, api);
return api;
}
/**
* Inject design mode context
*/
export function useDesignMode() {
const context = inject<ReturnType<typeof createDesignMode>>(DESIGN_MODE_KEY);
if (!context) {
throw new Error('useDesignMode must be used within a component that has called createDesignMode');
}
return context;
}

View File

@@ -0,0 +1,367 @@
import { ref } from 'vue';
import type { UpdateState, UpdateResult, UpdateManagerConfig } from '@xagi/design-mode-shared/types';
import type { SourceInfo } from '@xagi/design-mode-shared/messages';
import { extractSourceInfo, hasSourceMapping } from '@xagi/design-mode-shared/sourceInfo';
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
import { resolveSourceInfo } from '@xagi/design-mode-shared/sourceInfoResolver';
/**
* Vue3 composable for edit management
* Handles contentEditable mode, real-time sync, and update operations
*/
export function useEditManager(
processUpdate: (update: UpdateState) => Promise<UpdateResult>,
config: UpdateManagerConfig
) {
const lastRealtimeNotify = ref(0);
const REALTIME_THROTTLE_MS = 300;
/**
* Handle direct edit (double click or mutation)
*/
const handleDirectEdit = (element: HTMLElement, type: 'content' | 'style') => {
if (type === 'content') {
editTextContent(element);
} else {
editStyle(element);
}
};
/**
* Edit text content using contentEditable
*/
const editTextContent = async (element: HTMLElement) => {
const sourceInfo = resolveSourceInfo(element);
if (!sourceInfo) return;
const hasStaticContent = element.hasAttribute(AttributeNames.staticContent);
if (!hasStaticContent) {
console.warn('[EditManager] Cannot edit: element does not have static-content attribute. This might be a component definition, not a usage site.');
return;
}
const originalText = element.innerText;
const originalContentEditable = element.contentEditable;
element.contentEditable = 'true';
element.setAttribute('data-ignore-mutation', 'true');
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const handleSave = () => {
const newText = element.innerText.trim();
element.contentEditable = 'false';
element.removeAttribute('data-ignore-mutation');
element.removeEventListener('blur', handleSave);
element.removeEventListener('input', handleInput);
element.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside, true);
if (newText !== originalText.trim()) {
element.innerText = newText;
const relatedElements = findAllElementsWithSameSource(element, sourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.innerText = newText;
}
});
notifyContentChanged(element, newText, sourceInfo, originalText);
}
};
const handleCancel = () => {
element.innerText = originalText;
element.contentEditable = 'false';
element.removeAttribute('data-ignore-mutation');
element.removeEventListener('blur', handleSave);
element.removeEventListener('input', handleInput);
element.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside, true);
};
const handleClickOutside = (e: MouseEvent) => {
if (!element.contains(e.target as Node)) {
handleSave();
}
};
const handleInput = () => {
const currentText = element.innerText.trim();
const relatedElements = findAllElementsWithSameSource(element, sourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.innerText = currentText;
}
});
notifyContentChangedRealtime(element, currentText, sourceInfo, originalText);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
if (e.ctrlKey || e.metaKey) {
return;
} else {
e.preventDefault();
element.blur();
}
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
element.addEventListener('blur', handleSave);
element.addEventListener('input', handleInput);
element.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside, true);
element.focus();
};
/**
* Update content
*/
const updateContent = async (
element: HTMLElement,
newValue: string,
sourceInfo?: SourceInfo,
oldValue?: string
): Promise<UpdateResult> => {
const finalSourceInfo = sourceInfo || extractSourceInfo(element);
if (!finalSourceInfo) {
throw new Error('Cannot update content: no source info available');
}
const finalOldValue = oldValue ?? element.innerText;
const update: UpdateState = {
id: generateUpdateId(),
operation: 'content_update',
sourceInfo: finalSourceInfo,
element,
oldValue: finalOldValue,
newValue,
status: 'pending',
timestamp: Date.now(),
retryCount: 0,
persist: false,
};
const relatedElements = findAllElementsWithSameSource(element, finalSourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.innerText = newValue;
}
});
return processUpdate(update);
};
/**
* Update style (class)
*/
const updateStyle = async (
element: HTMLElement,
newClass: string,
sourceInfo: SourceInfo
): Promise<UpdateResult> => {
const oldClass = element.className;
const finalSourceInfo = sourceInfo || extractSourceInfo(element);
if (!finalSourceInfo) {
throw new Error('Cannot update style: no source info available');
}
const update: UpdateState = {
id: generateUpdateId(),
operation: 'class_update',
sourceInfo,
element,
oldValue: oldClass,
newValue: newClass,
status: 'pending',
timestamp: Date.now(),
retryCount: 0,
persist: false,
};
const relatedElements = findAllElementsWithSameSource(element, finalSourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.className = newClass;
}
});
return processUpdate(update);
};
/**
* Edit style (trigger UI)
*/
const editStyle = (element: HTMLElement) => {
// Placeholder for style editing UI
};
/**
* Update attribute
*/
const updateAttribute = async (
element: HTMLElement,
attributeName: string,
newValue: string,
sourceInfo: SourceInfo
): Promise<UpdateResult> => {
const oldValue = element.getAttribute(attributeName) || '';
const update: UpdateState = {
id: generateUpdateId(),
operation: 'attribute_update',
sourceInfo,
element,
oldValue,
newValue,
status: 'pending',
timestamp: Date.now(),
retryCount: 0,
persist: false,
};
const relatedElements = findAllElementsWithSameSource(element, sourceInfo);
relatedElements.forEach(el => {
if (el !== element) {
el.setAttribute(attributeName, newValue);
}
});
return processUpdate(update);
};
/**
* Edit attributes (trigger UI)
*/
const editAttributes = (element: HTMLElement) => {
// Placeholder for attribute editing UI
};
const generateUpdateId = (): string => {
return `update-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
/**
* Notify parent of final text (iframe only); does not write source files.
*/
const notifyContentChanged = (
element: HTMLElement,
newValue: string,
sourceInfo?: SourceInfo,
oldValue?: string
): void => {
const finalSourceInfo = sourceInfo || resolveSourceInfo(element);
if (!finalSourceInfo) {
console.warn('[EditManager] Cannot notify: no source info available');
return;
}
if (window.self !== window.top) {
window.parent.postMessage({
type: 'CONTENT_UPDATED',
payload: {
sourceInfo: finalSourceInfo,
oldValue: oldValue || '',
newValue: newValue,
},
timestamp: Date.now(),
}, '*');
}
};
/**
* Throttled CONTENT_UPDATED while typing (realtime: true).
*/
const notifyContentChangedRealtime = (
element: HTMLElement,
newValue: string,
sourceInfo?: SourceInfo,
oldValue?: string
): void => {
const now = Date.now();
if (now - lastRealtimeNotify.value < REALTIME_THROTTLE_MS) {
return;
}
lastRealtimeNotify.value = now;
const finalSourceInfo = sourceInfo || resolveSourceInfo(element);
if (!finalSourceInfo) {
return;
}
if (window.self !== window.top) {
window.parent.postMessage({
type: 'CONTENT_UPDATED',
payload: {
sourceInfo: finalSourceInfo,
oldValue: oldValue || '',
newValue: newValue,
realtime: true,
},
timestamp: Date.now(),
}, '*');
}
};
/**
* List peers that share the same logical instance (lists): same `element-id`,
* same static-content presence, same mapped file.
*/
const findAllElementsWithSameSource = (element: HTMLElement, sourceInfo?: SourceInfo): HTMLElement[] => {
const elementId = element.getAttribute(AttributeNames.elementId);
const hasStaticContent = element.hasAttribute(AttributeNames.staticContent);
const refSourceInfo = extractSourceInfo(element);
const refFileName = refSourceInfo?.fileName;
if (!elementId) {
console.warn('[EditManager] Element missing element-id attribute:', element);
return [element];
}
const allElementsWithId = Array.from(
document.querySelectorAll(`[${AttributeNames.elementId}]`)
) as HTMLElement[];
return allElementsWithId.filter(el => {
const elId = el.getAttribute(AttributeNames.elementId);
const elHasStaticContent = el.hasAttribute(AttributeNames.staticContent);
const elSourceInfo = extractSourceInfo(el);
const elFileName = elSourceInfo?.fileName;
if (elId !== elementId) return false;
if (elHasStaticContent !== hasStaticContent) return false;
if (elFileName !== refFileName) return false;
return true;
});
};
return {
handleDirectEdit,
editTextContent,
updateContent,
updateStyle,
editStyle,
updateAttribute,
editAttributes,
};
}

View File

@@ -0,0 +1,98 @@
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { hasSourceMapping } from '@xagi/design-mode-shared/sourceInfo';
/**
* Vue3 composable for MutationObserver management
* Watches DOM changes and triggers callbacks for content/style edits
*/
export function useObserverManager(
onEdit: (target: HTMLElement, type: 'content' | 'style') => void,
onNodeAdded: (node: HTMLElement) => void
) {
const observer = ref<MutationObserver | null>(null);
const isEnabled = ref(false);
const enable = () => {
if (observer.value) return;
observer.value = new MutationObserver(mutations => {
mutations.forEach(mutation => {
// Check if the mutation should be ignored
const targetNode = mutation.target;
const targetElement = targetNode.nodeType === Node.ELEMENT_NODE
? targetNode as HTMLElement
: targetNode.parentElement;
if (targetElement && targetElement.hasAttribute('data-ignore-mutation')) {
return;
}
if (mutation.type === 'childList') {
// Handle element addition
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
onNodeAdded(node as HTMLElement);
}
});
} else if (mutation.type === 'characterData') {
// Handle text content change
const target = mutation.target.parentElement as HTMLElement;
if (target && hasSourceMapping(target)) {
if (target.hasAttribute('data-ignore-mutation')) {
return;
}
onEdit(target, 'content');
}
} else if (mutation.type === 'attributes') {
// Handle attribute change (style, class)
const target = mutation.target as HTMLElement;
if (target && hasSourceMapping(target)) {
const attributeName = mutation.attributeName;
const newValue = target.getAttribute(attributeName!);
const oldValue = mutation.oldValue;
if (newValue === oldValue) {
return;
}
if (attributeName === 'class') {
onEdit(target, 'style');
} else if (attributeName?.startsWith('style')) {
onEdit(target, 'style');
}
}
}
});
});
observer.value.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeOldValue: true,
attributeFilter: ['class', 'style'],
});
isEnabled.value = true;
};
const disable = () => {
if (observer.value) {
observer.value.disconnect();
observer.value = null;
isEnabled.value = false;
}
};
onBeforeUnmount(() => {
disable();
});
return {
observer,
isEnabled,
enable,
disable,
};
}

View File

@@ -0,0 +1,539 @@
import { ref, onMounted, onBeforeUnmount } from 'vue';
import type { ElementInfo, SourceInfo } from '@xagi/design-mode-shared/messages';
import { AttributeNames, isSourceMappingAttribute } from '@xagi/design-mode-shared/attributeNames';
import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils';
import { extractSourceInfo as extractSourceInfoFromAttributes } from '@xagi/design-mode-shared/sourceInfo';
export interface SelectionManagerConfig {
enableSelection: boolean;
enableHover: boolean;
selectionDelay: number;
excludeSelectors: string[];
includeOnlyElements: boolean;
}
/**
* Vue3 composable for selection management
* Handles click/hover selection for mapped elements
*/
export function useSelectionManager(
container: HTMLElement,
config: SelectionManagerConfig = {
enableSelection: true,
enableHover: true,
selectionDelay: 0,
excludeSelectors: [
'script',
'style',
'meta',
'link',
'head',
'title',
'html',
'body',
'[data-selection-exclude="true"]',
'.no-selection'
],
includeOnlyElements: false
}
) {
const selectedElement = ref<HTMLElement | null>(null);
const hoverElement = ref<HTMLElement | null>(null);
const isSelecting = ref(false);
const selectionStartTime = ref(0);
const preventNextClick = ref(false);
const selectionListeners = new Set<(element: HTMLElement | null) => void>();
/**
* Whether element may be selected (mapped + static-content or static-class)
*/
const isValidElement = (element: HTMLElement): boolean => {
if (!element || !element.tagName) return false;
// Exclude context menu
if (element.closest(`[${AttributeNames.contextMenu}="true"]`)) return false;
// Exclusion list
for (const selector of config.excludeSelectors) {
if (element.matches(selector) || element.closest(selector)) {
return false;
}
}
// Optional tag whitelist
if (config.includeOnlyElements) {
const validElements = ['DIV', 'SPAN', 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BUTTON', 'A'];
if (!validElements.includes(element.tagName)) {
return false;
}
}
// Must have -info JSON
if (!element.hasAttribute(AttributeNames.info)) {
return false;
}
// Need static-content or static-class
const hasStaticContent = element.hasAttribute(AttributeNames.staticContent);
const hasStaticClass = element.hasAttribute(AttributeNames.staticClass);
if (!hasStaticContent && !hasStaticClass) {
return false;
}
return true;
};
/**
* Apply selection highlight
*/
const selectElement = (element: HTMLElement) => {
if (selectedElement.value === element) return;
// Clear previous outline
if (selectedElement.value) {
clearElementHighlighting(selectedElement.value);
}
selectedElement.value = element;
// Highlight new node
highlightElement(element);
// Notify subscribers
selectionListeners.forEach(listener => listener(element));
};
/**
* clearSelection
*/
const clearSelection = () => {
if (selectedElement.value) {
clearElementHighlighting(selectedElement.value);
selectedElement.value = null;
selectionListeners.forEach(listener => listener(null));
}
};
/**
* highlightElement
*/
const highlightElement = (element: HTMLElement) => {
// Restore prior inline styles if any
const existingHighlight = element.getAttribute('data-selection-highlight');
if (existingHighlight) {
const existingStyles = JSON.parse(existingHighlight);
Object.entries(existingStyles).forEach(([property, value]) => {
(element.style as any)[property] = value;
});
}
// Snapshot pre-highlight
const originalStyles = {
outline: element.style.outline,
boxShadow: element.style.boxShadow,
backgroundColor: element.style.backgroundColor,
cursor: element.style.cursor
};
// Selection chrome
element.style.outline = '2px solid #007acc';
element.style.boxShadow = '0 0 0 2px rgba(0, 122, 204, 0.3)';
element.style.backgroundColor = 'rgba(0, 122, 204, 0.1)';
element.style.cursor = 'pointer';
// Persist snapshot on data-selection-highlight
element.setAttribute('data-selection-highlight', JSON.stringify(originalStyles));
};
/**
* clearElementHighlighting
*/
const clearElementHighlighting = (element: HTMLElement) => {
const highlightData = element.getAttribute('data-selection-highlight');
if (highlightData) {
try {
const originalStyles = JSON.parse(highlightData);
Object.entries(originalStyles).forEach(([property, value]) => {
(element.style as any)[property] = value;
});
element.removeAttribute('data-selection-highlight');
} catch (e) {
console.warn('[SelectionManager] Failed to restore original styles:', e);
}
}
};
/**
* onHoverElement
*/
const onHoverElement = (element: HTMLElement | null) => {
// Hook for hover UX
};
/**
* selectAll (first candidate only)
*/
const selectAll = () => {
const selectableElements = Array.from(
container.querySelectorAll('*')
).filter(el => isValidElement(el as HTMLElement));
if (selectableElements.length > 0) {
selectElement(selectableElements[0] as HTMLElement);
}
};
/**
* Click handler
*/
const handleClick = (event: MouseEvent) => {
if (!config.enableSelection) return;
if (preventNextClick.value) {
event.preventDefault();
event.stopPropagation();
preventNextClick.value = false;
return;
}
const target = event.target as HTMLElement;
if (!isValidElement(target)) return;
// Optional selectionDelay
if (config.selectionDelay > 0) {
setTimeout(() => {
selectElement(target);
}, config.selectionDelay);
} else {
selectElement(target);
}
};
/**
* mousedown
*/
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!isValidElement(target)) return;
isSelecting.value = true;
selectionStartTime.value = Date.now();
preventNextClick.value = false;
};
/**
* mouseup
*/
const handleMouseUp = (event: MouseEvent) => {
if (!isSelecting.value) return;
const target = event.target as HTMLElement;
const duration = Date.now() - selectionStartTime.value;
// Long-press (>500ms) suppresses click selection
if (duration > 500) {
preventNextClick.value = true;
}
isSelecting.value = false;
};
/**
* mouseenter
*/
const handleMouseEnter = (event: MouseEvent) => {
if (!config.enableHover) return;
const target = event.target as HTMLElement;
if (!isValidElement(target)) return;
hoverElement.value = target;
onHoverElement(target);
};
/**
* mouseleave
*/
const handleMouseLeave = (event: MouseEvent) => {
if (!config.enableHover) return;
const target = event.target as HTMLElement;
if (hoverElement.value === target) {
hoverElement.value = null;
onHoverElement(null);
}
};
/**
* keydown
*/
const handleKeyDown = (event: KeyboardEvent) => {
// Esc clears selection
if (event.key === 'Escape') {
clearSelection();
}
// Ctrl/Cmd+A: select first match
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
event.preventDefault();
selectAll();
}
// Ctrl/Cmd+D: clear
if ((event.ctrlKey || event.metaKey) && event.key === 'd') {
event.preventDefault();
clearSelection();
}
};
/**
* keyup
*/
const handleKeyUp = (event: KeyboardEvent) => {
// noop
};
/**
* extractSourceInfo
*/
const extractSourceInfo = (element: HTMLElement): SourceInfo | null => {
return extractSourceInfoFromAttributes(element);
};
/**
* Walk up for library component root
*/
const findComponentRoot = (element: HTMLElement): HTMLElement => {
const sourceInfo = extractSourceInfo(element);
if (!sourceInfo || !sourceInfo.fileName) return element;
// Heuristic: components/ui or common
const isLibraryComponent = sourceInfo.fileName.includes('/components/ui/') ||
sourceInfo.fileName.includes('/components/common/');
if (!isLibraryComponent) return element;
// Stop at file boundary
let current = element;
let componentRoot = element;
while (current.parentElement) {
const parent = current.parentElement;
const parentSourceInfo = extractSourceInfo(parent);
// Boundary: different file or no mapping
if (!parentSourceInfo || parentSourceInfo.fileName !== sourceInfo.fileName) {
componentRoot = current;
break;
}
current = parent;
}
return componentRoot;
};
/**
* extractElementInfo
*/
const extractElementInfo = (element: HTMLElement): ElementInfo | null => {
if (!element) return null;
// Prefer library root
const targetElement = findComponentRoot(element);
const sourceInfo = extractSourceInfo(targetElement);
if (!sourceInfo) {
console.warn('[SelectionManager] Element has no source mapping:', targetElement);
return null;
}
// Layout (rect unused below — kept for future)
const rect = targetElement.getBoundingClientRect();
const computedStyle = window.getComputedStyle(targetElement);
// Static text flags
const hasStaticContentAttr = targetElement.hasAttribute(AttributeNames.staticContent);
const isActuallyPureText = isPureStaticText(targetElement);
const isStaticText = hasStaticContentAttr && isActuallyPureText;
const isStaticClass = targetElement.hasAttribute(AttributeNames.staticClass);
// Text snapshot
let textContent = '';
if (isStaticText) {
textContent = getElementTextContent(targetElement);
} else {
textContent = targetElement.innerText || targetElement.textContent || '';
}
// Ancestor chain with mappings
const hierarchy: { tagName: string; componentName?: string; fileName?: string }[] = [];
let current: HTMLElement | null = targetElement;
while (current && current !== document.body) {
const info = extractSourceInfo(current);
if (info) {
hierarchy.push({
tagName: current.tagName.toLowerCase(),
componentName: info.componentName,
fileName: info.fileName
});
}
current = current.parentElement;
}
// DOM attrs as pseudo-props
const props = getElementAttributes(targetElement);
return {
tagName: targetElement.tagName.toLowerCase(),
className: targetElement.className || '',
textContent: textContent,
sourceInfo,
isStaticText: isStaticText || false,
isStaticClass: isStaticClass,
componentName: sourceInfo.componentName,
componentPath: sourceInfo.fileName,
props,
hierarchy
};
};
/**
* getElementTextContent
*/
const getElementTextContent = (element: HTMLElement): string => {
let textContent = element.textContent || '';
// Truncate preview
if (textContent.length > 100) {
textContent = textContent.substring(0, 100) + '...';
}
return textContent.trim();
};
/**
* getElementAttributes
*/
const getElementAttributes = (element: HTMLElement): Record<string, string> => {
const attributes: Record<string, string> = {};
const elementAttributes = Array.from(element.attributes);
elementAttributes.forEach(attr => {
// Strip mapping + selection attrs
if (!isSourceMappingAttribute(attr.name) &&
!attr.name.startsWith('data-selection-')) {
attributes[attr.name] = attr.value;
}
});
return attributes;
};
/**
* getElementDomPath
*/
const getElementDomPath = (element: HTMLElement): string => {
const path: string[] = [];
let current: HTMLElement | null = element;
while (current && current !== container) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector += `#${current.id}`;
path.unshift(selector);
break;
}
if (current.className) {
const classes = Array.from(current.classList).slice(0, 3);
selector += `.${classes.join('.')}`;
}
// nth-of-type disambiguation
const siblings = Array.from(current.parentNode?.children || []);
const sameTagSiblings = siblings.filter(sibling =>
sibling.tagName === current!.tagName
);
if (sameTagSiblings.length > 1) {
const index = sameTagSiblings.indexOf(current);
selector += `:nth-of-type(${index + 1})`;
}
path.unshift(selector);
current = current.parentElement;
}
return path.join(' > ');
};
/**
* addSelectionListener
*/
const addSelectionListener = (listener: (element: HTMLElement | null) => void) => {
selectionListeners.add(listener);
return () => selectionListeners.delete(listener);
};
/**
* Initialize event listeners
*/
const initializeEventListeners = () => {
if (!config.enableSelection) return;
container.addEventListener('click', handleClick, true);
container.addEventListener('mousedown', handleMouseDown, true);
container.addEventListener('mouseup', handleMouseUp, true);
if (config.enableHover) {
container.addEventListener('mouseenter', handleMouseEnter, true);
container.addEventListener('mouseleave', handleMouseLeave, true);
}
// Keyboard shortcuts when selection enabled
if (config.enableSelection) {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
}
};
/**
* destroy
*/
const destroy = () => {
clearSelection();
container.removeEventListener('click', handleClick, true);
container.removeEventListener('mousedown', handleMouseDown, true);
container.removeEventListener('mouseup', handleMouseUp, true);
container.removeEventListener('mouseenter', handleMouseEnter, true);
container.removeEventListener('mouseleave', handleMouseLeave, true);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
selectionListeners.clear();
};
onMounted(() => {
initializeEventListeners();
});
onBeforeUnmount(() => {
destroy();
});
return {
selectedElement,
hoverElement,
isSelecting,
selectElement,
clearSelection,
extractElementInfo,
addSelectionListener,
getSelectedElement: () => selectedElement.value,
getHoverElement: () => hoverElement.value,
};
}

View File

@@ -0,0 +1,50 @@
import { ref } from 'vue';
export interface ToastItem {
id: string;
message: string;
type: 'success' | 'error' | 'info';
duration: number;
}
const toasts = ref<ToastItem[]>([]);
export function useToast() {
const show = (message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) => {
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
const toast: ToastItem = { id, message, type, duration };
toasts.value.push(toast);
setTimeout(() => {
remove(id);
}, duration + 300); // Add animation time
};
const remove = (id: string) => {
const index = toasts.value.findIndex(t => t.id === id);
if (index > -1) {
toasts.value.splice(index, 1);
}
};
const error = (message: string, duration = 4000) => {
show(message, 'error', duration);
};
const success = (message: string, duration = 3000) => {
show(message, 'success', duration);
};
const info = (message: string, duration = 3000) => {
show(message, 'info', duration);
};
return {
toasts,
show,
remove,
error,
success,
info,
};
}