"添加前端模板和运行代码模块"
This commit is contained in:
@@ -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
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user