"添加前端模板和运行代码模块"
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch, unref } from 'vue';
|
||||
import { createDesignMode } from './composables/useDesignMode';
|
||||
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
|
||||
|
||||
// Create and provide design mode context
|
||||
const designMode = createDesignMode();
|
||||
|
||||
// Global event handlers
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('#__vite_plugin_design_mode__')) return;
|
||||
if ((e.target as HTMLElement).closest(`[${AttributeNames.contextMenu}="true"]`)) return;
|
||||
|
||||
if (
|
||||
(e.target as HTMLElement).closest(`[${AttributeNames.staticContent}="true"]`) ||
|
||||
(e.target as HTMLElement).closest(`[${AttributeNames.staticClass}="true"]`)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = e.target as HTMLElement;
|
||||
designMode.selectElement(target);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('#__vite_plugin_design_mode__')) return;
|
||||
if ((e.target as HTMLElement).closest(`[${AttributeNames.contextMenu}="true"]`)) return;
|
||||
|
||||
if (
|
||||
(e.target as HTMLElement).hasAttribute(AttributeNames.staticContent) ||
|
||||
(e.target as HTMLElement).hasAttribute(AttributeNames.staticClass)
|
||||
) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.setAttribute('data-design-hover', 'true');
|
||||
designMode.setHoveredElement(target);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseOut = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.hasAttribute(AttributeNames.contextMenuHover)) {
|
||||
target.removeAttribute('data-design-hover');
|
||||
}
|
||||
designMode.setHoveredElement(null);
|
||||
};
|
||||
|
||||
// Watch design mode state(API 上为 ComputedRef,需 unref 才能拿到布尔值)
|
||||
watch(
|
||||
() => unref(designMode.isDesignMode),
|
||||
isDesignMode => {
|
||||
if (!isDesignMode) {
|
||||
document.querySelectorAll('[data-design-selected]').forEach(el => {
|
||||
el.removeAttribute('data-design-selected');
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Watch selected element
|
||||
watch(
|
||||
() => unref(designMode.selectedElement),
|
||||
(selectedElement, oldElement) => {
|
||||
if (oldElement) {
|
||||
oldElement.removeAttribute('data-design-selected');
|
||||
}
|
||||
if (selectedElement) {
|
||||
selectedElement.setAttribute('data-design-selected', 'true');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
if (!unref(designMode.isDesignMode)) return;
|
||||
|
||||
document.addEventListener('click', handleClick, true);
|
||||
document.addEventListener('mouseover', handleMouseOver);
|
||||
document.addEventListener('mouseout', handleMouseOut);
|
||||
|
||||
// Inject global styles
|
||||
const styleId = 'appdev-design-mode-styles';
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
[data-design-hover="true"] {
|
||||
outline: 2px dashed #60a5fa !important;
|
||||
outline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-design-selected="true"] {
|
||||
outline: 2px solid #2563eb !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
[contenteditable="true"] {
|
||||
outline: 2px solid #22c55e !important;
|
||||
cursor: text;
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
document.removeEventListener('mouseover', handleMouseOver);
|
||||
document.removeEventListener('mouseout', handleMouseOut);
|
||||
|
||||
const style = document.getElementById('appdev-design-mode-styles');
|
||||
if (style) {
|
||||
style.remove();
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-design-hover]').forEach(el => {
|
||||
el.removeAttribute('data-design-hover');
|
||||
});
|
||||
});
|
||||
|
||||
// Re-setup listeners when design mode changes
|
||||
watch(
|
||||
() => unref(designMode.isDesignMode),
|
||||
isDesignMode => {
|
||||
if (isDesignMode) {
|
||||
document.addEventListener('click', handleClick, true);
|
||||
document.addEventListener('mouseover', handleMouseOver);
|
||||
document.addEventListener('mouseout', handleMouseOut);
|
||||
|
||||
const styleId = 'appdev-design-mode-styles';
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
[data-design-hover="true"] {
|
||||
outline: 2px dashed #60a5fa !important;
|
||||
outline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-design-selected="true"] {
|
||||
outline: 2px solid #2563eb !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
[contenteditable="true"] {
|
||||
outline: 2px solid #22c55e !important;
|
||||
cursor: text;
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
} else {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
document.removeEventListener('mouseover', handleMouseOver);
|
||||
document.removeEventListener('mouseout', handleMouseOut);
|
||||
|
||||
document.querySelectorAll('[data-design-hover]').forEach(el => {
|
||||
el.removeAttribute('data-design-hover');
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<!--
|
||||
与 @xagi/design-mode-client-react 注入入口对齐:React 端仅挂载 DesignModeProvider + DesignModeManager,
|
||||
DesignModeManager 返回 null,不在 iframe 内渲染「Design Mode」开关/编辑面板。
|
||||
设计模式开关与样式编辑由父应用(XAGI shell 等)通过 TOGGLE_DESIGN_MODE / 后续消息驱动。
|
||||
若宿主需要本地面板,可从包内导出自行挂载 DesignModeUI.vue / ToastContainer.vue。
|
||||
-->
|
||||
<template></template>
|
||||
296
qiming-vite-plugin-design-mode/packages/client-vue/src/README.md
Normal file
296
qiming-vite-plugin-design-mode/packages/client-vue/src/README.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Vue3 Design Mode Client
|
||||
|
||||
Vue3 Single File Component implementation of the design mode UI, ported from React.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/client-vue/
|
||||
├── DesignModeApp.vue # Root component (160 lines)
|
||||
├── components/
|
||||
│ ├── ContextMenu.vue # Right-click context menu (175 lines)
|
||||
│ ├── DesignModeUI.vue # Main UI with toggle & edit panel (333 lines)
|
||||
│ ├── Toast.vue # Toast notification component (101 lines)
|
||||
│ └── ToastContainer.vue # Toast container manager (31 lines)
|
||||
├── composables/
|
||||
│ ├── useDesignMode.ts # Design mode state & actions (210 lines)
|
||||
│ ├── useEditManager.ts # Direct editing logic (367 lines)
|
||||
│ ├── useObserverManager.ts # MutationObserver management (99 lines)
|
||||
│ ├── useSelectionManager.ts # Selection & hover logic (540 lines)
|
||||
│ └── useToast.ts # Toast notification system (50 lines)
|
||||
├── index.ts # Entry point with Shadow DOM (32 lines)
|
||||
└── exports.ts # Public API exports (7 lines)
|
||||
```
|
||||
|
||||
**Total: ~2,103 lines**
|
||||
|
||||
## Components
|
||||
|
||||
### 1. DesignModeApp.vue
|
||||
Root component that:
|
||||
- Creates and provides design mode context via `createDesignMode()`
|
||||
- Sets up global event listeners (click, hover, mouseout)
|
||||
- Injects global CSS for hover/selected states
|
||||
- Manages lifecycle and cleanup
|
||||
|
||||
### 2. DesignModeUI.vue
|
||||
Main UI component featuring:
|
||||
- **Toggle Button**: Fixed bottom-right position
|
||||
- **Edit Panel**: 320px wide panel when element selected
|
||||
- **Tailwind Presets**:
|
||||
- Background colors (7 options)
|
||||
- Text colors (5 options)
|
||||
- Padding (6 options)
|
||||
- Border radius (5 options)
|
||||
- **Reset Button**: Reload page to reset all modifications
|
||||
|
||||
### 3. ContextMenu.vue
|
||||
Right-click context menu with:
|
||||
- Dynamic positioning with viewport boundary checks
|
||||
- Menu items with hover states
|
||||
- Close handlers: click outside, ESC, scroll, resize
|
||||
- Hover state restoration after close
|
||||
|
||||
### 4. Toast.vue & ToastContainer.vue
|
||||
Toast notification system:
|
||||
- Three types: success, error, info
|
||||
- Slide-in/out animations
|
||||
- Auto-dismiss (3-4s configurable)
|
||||
- Stacked display at top-center
|
||||
|
||||
## Composables
|
||||
|
||||
### useDesignMode.ts
|
||||
Core design mode state management:
|
||||
- `isDesignMode`: Boolean toggle state
|
||||
- `selectedElement`: Currently selected element
|
||||
- `hoveredElement`: Currently hovered element
|
||||
- `toggleDesignMode()`: Toggle design mode on/off
|
||||
- `selectElement()`: Select element and notify parent
|
||||
- `modifyElementClass()`: Update element classes
|
||||
- `updateElementContent()`: Update element text content
|
||||
- `resetModifications()`: Reload page
|
||||
|
||||
Uses Vue3 `provide/inject` pattern for global state.
|
||||
|
||||
### useToast.ts
|
||||
Toast notification management:
|
||||
- `show()`: Display toast with type and duration
|
||||
- `error()`: Show error toast (4s)
|
||||
- `success()`: Show success toast (3s)
|
||||
- `info()`: Show info toast (3s)
|
||||
- `remove()`: Remove toast by ID
|
||||
|
||||
### useEditManager.ts
|
||||
Direct editing functionality:
|
||||
- Double-click to edit text content
|
||||
- ContentEditable mode with save/cancel
|
||||
- Real-time sync across list items
|
||||
- Keyboard shortcuts (Enter to save, ESC to cancel)
|
||||
|
||||
### useObserverManager.ts
|
||||
MutationObserver for DOM changes:
|
||||
- Watches content and style changes
|
||||
- Filters ignored mutations (`data-ignore-mutation`)
|
||||
- Triggers callbacks for edits
|
||||
|
||||
### useSelectionManager.ts
|
||||
Advanced selection management:
|
||||
- Click/hover selection
|
||||
- Element validation (static-content/static-class)
|
||||
- Highlight/unhighlight elements
|
||||
- Keyboard shortcuts (ESC to clear)
|
||||
- Component root detection
|
||||
|
||||
## Usage
|
||||
|
||||
### Entry Point (index.ts)
|
||||
```typescript
|
||||
import { createApp } from 'vue';
|
||||
import DesignModeApp from './DesignModeApp.vue';
|
||||
|
||||
// Creates Shadow DOM container
|
||||
// Mounts Vue app
|
||||
// Detects iframe environment
|
||||
```
|
||||
|
||||
### In Components
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useDesignMode } from './composables/useDesignMode';
|
||||
|
||||
const designMode = useDesignMode();
|
||||
|
||||
// Access state
|
||||
console.log(designMode.isDesignMode);
|
||||
console.log(designMode.selectedElement);
|
||||
|
||||
// Actions
|
||||
designMode.toggleDesignMode();
|
||||
designMode.selectElement(element);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Toast Notifications
|
||||
```typescript
|
||||
import { useToast } from './composables/useToast';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
toast.success('Changes saved!');
|
||||
toast.error('Failed to update');
|
||||
toast.info('Element selected');
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Shadow DOM Isolation
|
||||
- Styles isolated from host page
|
||||
- No CSS conflicts
|
||||
- Clean encapsulation
|
||||
|
||||
### 2. Iframe Communication
|
||||
- PostMessage API for parent ↔ iframe
|
||||
- Message types: ELEMENT_SELECTED, STYLE_UPDATED, CONTENT_UPDATED
|
||||
- Automatic source info resolution
|
||||
|
||||
### 3. List Synchronization
|
||||
- Elements with same `element-id` update together
|
||||
- Real-time preview across all instances
|
||||
- Prevents desync in mapped lists
|
||||
|
||||
### 4. Tailwind Integration
|
||||
- Preset buttons for common utilities
|
||||
- Visual color pickers
|
||||
- Class merging (simple Set-based)
|
||||
|
||||
### 5. Keyboard Shortcuts
|
||||
- **Enter**: Save content edit
|
||||
- **ESC**: Cancel edit or clear selection
|
||||
- **Ctrl/Cmd+A**: Select first element
|
||||
- **Ctrl/Cmd+D**: Clear selection
|
||||
|
||||
## Differences from React Version
|
||||
|
||||
### Architecture
|
||||
- **React**: Context API + hooks
|
||||
- **Vue3**: Provide/Inject + composables
|
||||
|
||||
### State Management
|
||||
- **React**: `useState`, `useEffect`
|
||||
- **Vue3**: `ref`, `watch`, `onMounted`
|
||||
|
||||
### Event Handling
|
||||
- **React**: Inline event handlers
|
||||
- **Vue3**: `@click`, `@mouseenter` directives
|
||||
|
||||
### Styling
|
||||
- **React**: Inline style objects
|
||||
- **Vue3**: `:style` bindings + scoped CSS
|
||||
|
||||
### Lifecycle
|
||||
- **React**: `useEffect` with cleanup
|
||||
- **Vue3**: `onMounted`, `onUnmounted`
|
||||
|
||||
## Shared Utilities
|
||||
|
||||
Both React and Vue3 clients use shared utilities from `client-shared/`:
|
||||
|
||||
- `attributeNames.ts`: Data attribute name resolution
|
||||
- `sourceInfo.ts`: Source mapping extraction
|
||||
- `sourceInfoResolver.ts`: Advanced source resolution
|
||||
- `elementUtils.ts`: Element validation helpers
|
||||
- `bridge.ts`: Iframe bridge communication
|
||||
- `UpdateService.ts`: Update API client
|
||||
- `HistoryManager.ts`: Undo/redo functionality
|
||||
|
||||
## Integration
|
||||
|
||||
### Vite Plugin Configuration
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import appdevDesignMode from 'vite-plugin-appdev-design-mode';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
appdevDesignMode({
|
||||
framework: 'vue3', // Use Vue3 client
|
||||
enabled: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Build Output
|
||||
The plugin will:
|
||||
1. Inject Vue3 client code into your app
|
||||
2. Create Shadow DOM container
|
||||
3. Mount DesignModeApp component
|
||||
4. Enable design mode features
|
||||
|
||||
## API Reference
|
||||
|
||||
### createDesignMode()
|
||||
Creates design mode context. Call once in root component.
|
||||
|
||||
**Returns**: `DesignModeContext`
|
||||
|
||||
### useDesignMode()
|
||||
Access design mode context. Must be called within a component that has `createDesignMode()` in its ancestor tree.
|
||||
|
||||
**Returns**: `DesignModeContext`
|
||||
|
||||
### DesignModeContext
|
||||
```typescript
|
||||
interface DesignModeContext {
|
||||
// State
|
||||
isDesignMode: Ref<boolean>;
|
||||
selectedElement: Ref<HTMLElement | null>;
|
||||
hoveredElement: Ref<HTMLElement | null>;
|
||||
|
||||
// Actions
|
||||
toggleDesignMode: () => void;
|
||||
selectElement: (element: HTMLElement | null) => void;
|
||||
setHoveredElement: (element: HTMLElement | null) => void;
|
||||
modifyElementClass: (element: HTMLElement, newClass: string) => Promise<void>;
|
||||
updateElementContent: (element: HTMLElement, newContent: string) => Promise<void>;
|
||||
resetModifications: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- Vue 3.x
|
||||
- TypeScript 5.x
|
||||
- Vite 5.x
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Type check
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Undo/redo functionality
|
||||
- [ ] Attribute editing UI
|
||||
- [ ] Style inspector panel
|
||||
- [ ] Component hierarchy viewer
|
||||
- [ ] Keyboard shortcut customization
|
||||
- [ ] Multi-select support
|
||||
- [ ] Drag-to-reorder elements
|
||||
- [ ] Copy/paste element styles
|
||||
- [ ] Export changes as patch file
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { AttributeNames } from '@xagi/design-mode-shared/attributeNames';
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
element: HTMLElement;
|
||||
x: number;
|
||||
y: number;
|
||||
menuItems: MenuItem[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
const menuStyle = ref({
|
||||
left: `${props.x}px`,
|
||||
top: `${props.y}px`,
|
||||
});
|
||||
|
||||
// Track mouse position for hover restoration
|
||||
let lastMouseX = 0;
|
||||
let lastMouseY = 0;
|
||||
|
||||
const trackMouse = (e: MouseEvent) => {
|
||||
lastMouseX = e.clientX;
|
||||
lastMouseY = e.clientY;
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (item.disabled) return;
|
||||
item.action();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Adjust position to stay within viewport
|
||||
if (menuRef.value) {
|
||||
const rect = menuRef.value.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (rect.right > viewportWidth) {
|
||||
menuStyle.value.left = `${viewportWidth - rect.width - 10}px`;
|
||||
}
|
||||
if (rect.bottom > viewportHeight) {
|
||||
menuStyle.value.top = `${viewportHeight - rect.height - 10}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// Setup event listeners with slight delay
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousemove', trackMouse);
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
document.addEventListener('contextmenu', handleContextMenu, true);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleScroll, true);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', trackMouse);
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
document.removeEventListener('contextmenu', handleContextMenu, true);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleScroll, true);
|
||||
|
||||
// Restore hover state
|
||||
const hadHoverState = props.element.hasAttribute(AttributeNames.contextMenuHover);
|
||||
if (hadHoverState) {
|
||||
props.element.removeAttribute(AttributeNames.contextMenuHover);
|
||||
|
||||
setTimeout(() => {
|
||||
const rect = props.element.getBoundingClientRect();
|
||||
const isMouseOver =
|
||||
lastMouseX >= rect.left &&
|
||||
lastMouseX <= rect.right &&
|
||||
lastMouseY >= rect.top &&
|
||||
lastMouseY <= rect.bottom;
|
||||
|
||||
if (!isMouseOver) {
|
||||
props.element.removeAttribute('data-design-hover');
|
||||
} else {
|
||||
const mouseEnterEvent = new MouseEvent('mouseenter', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX: lastMouseX,
|
||||
clientY: lastMouseY,
|
||||
});
|
||||
props.element.dispatchEvent(mouseEnterEvent);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="menuRef"
|
||||
:data-xagi-context-menu="true"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
left: menuStyle.left,
|
||||
top: menuStyle.top,
|
||||
background: 'white',
|
||||
border: '0.5px solid #ccc',
|
||||
borderRadius: '6px',
|
||||
padding: '0',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
|
||||
zIndex: '10000',
|
||||
minWidth: '150px',
|
||||
fontSize: '14px',
|
||||
}"
|
||||
>
|
||||
<template v-for="(item, index) in menuItems" :key="index">
|
||||
<!-- Separator -->
|
||||
<div
|
||||
v-if="item.label === '---' || item.disabled"
|
||||
style="height: 1px; background: #e5e7eb; margin: 0; padding: 0"
|
||||
/>
|
||||
<!-- Menu Item -->
|
||||
<div
|
||||
v-else
|
||||
@click="handleMenuItemClick(item)"
|
||||
@mouseenter="(e) => (e.currentTarget as HTMLElement).style.background = '#f0f0f0'"
|
||||
@mouseleave="(e) => (e.currentTarget as HTMLElement).style.background = 'transparent'"
|
||||
:style="{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
margin: '0',
|
||||
cursor: item.disabled ? 'not-allowed' : 'pointer',
|
||||
color: item.disabled ? '#999' : '#333',
|
||||
background: 'transparent',
|
||||
}"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, unref } from 'vue';
|
||||
import { useDesignMode } from '../composables/useDesignMode';
|
||||
|
||||
const designMode = useDesignMode();
|
||||
|
||||
/**
|
||||
* createDesignMode 注入的 API 上,isDesignMode / selectedElement 等是 ComputedRef。
|
||||
* 在 <script> 里直接写 designMode.selectedElement 得到的是 Ref 对象,不是 HTMLElement,
|
||||
* 会导致 v-if、watch、computed 判断错误(例如 el.tagName 为 undefined)。
|
||||
* 模板里 Vue 会对部分场景解包,但 script 中需显式 unref。
|
||||
*/
|
||||
const designModeOn = computed(() => unref(designMode.isDesignMode));
|
||||
const selectedEl = computed(() => unref(designMode.selectedElement));
|
||||
|
||||
// Tailwind Presets
|
||||
const TAILWIND_PRESETS = {
|
||||
bgColors: [
|
||||
{ label: 'White', value: 'bg-white', color: '#ffffff' },
|
||||
{ label: 'Slate 50', value: 'bg-slate-50', color: '#f8fafc' },
|
||||
{ label: 'Blue 50', value: 'bg-blue-50', color: '#eff6ff' },
|
||||
{ label: 'Blue 100', value: 'bg-blue-100', color: '#dbeafe' },
|
||||
{ label: 'Blue 600', value: 'bg-blue-600', color: '#2563eb' },
|
||||
{ label: 'Red 50', value: 'bg-red-50', color: '#fef2f2' },
|
||||
{ label: 'Green 50', value: 'bg-green-50', color: '#f0fdf4' },
|
||||
],
|
||||
textColors: [
|
||||
{ label: 'Slate 900', value: 'text-slate-900', color: '#0f172a' },
|
||||
{ label: 'Slate 600', value: 'text-slate-600', color: '#475569' },
|
||||
{ label: 'Blue 600', value: 'text-blue-600', color: '#2563eb' },
|
||||
{ label: 'White', value: 'text-white', color: '#000' },
|
||||
{ label: 'Red 600', value: 'text-red-600', color: '#dc2626' },
|
||||
],
|
||||
paddings: [
|
||||
{ label: '0', value: 'p-0' },
|
||||
{ label: '2', value: 'p-2' },
|
||||
{ label: '4', value: 'p-4' },
|
||||
{ label: '6', value: 'p-6' },
|
||||
{ label: '8', value: 'p-8' },
|
||||
{ label: '12', value: 'p-12' },
|
||||
],
|
||||
rounded: [
|
||||
{ label: 'None', value: 'rounded-none' },
|
||||
{ label: 'Small', value: 'rounded-sm' },
|
||||
{ label: 'Medium', value: 'rounded-md' },
|
||||
{ label: 'Large', value: 'rounded-lg' },
|
||||
{ label: 'Full', value: 'rounded-full' },
|
||||
],
|
||||
};
|
||||
|
||||
const currentClasses = ref('');
|
||||
|
||||
watch(selectedEl, element => {
|
||||
if (element) {
|
||||
currentClasses.value = element.className;
|
||||
} else {
|
||||
currentClasses.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const modifyClass = async (newClass: string) => {
|
||||
const el = selectedEl.value;
|
||||
if (!el) return;
|
||||
await designMode.modifyElementClass(el, newClass);
|
||||
currentClasses.value = el.className;
|
||||
};
|
||||
|
||||
const hasClass = (className: string) => {
|
||||
return currentClasses.value.includes(className);
|
||||
};
|
||||
|
||||
const getElementTag = computed(() => {
|
||||
const el = selectedEl.value;
|
||||
if (!el) return '';
|
||||
const tag = el.tagName?.toLowerCase() ?? '';
|
||||
return tag + (el.id ? `#${el.id}` : '');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
style="
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 99999;
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
"
|
||||
>
|
||||
<!-- Main Toggle -->
|
||||
<div
|
||||
style="
|
||||
pointer-events: auto;
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
"
|
||||
>
|
||||
<label style="font-size: 14px; font-weight: 500; color: #1e293b">
|
||||
Design Mode
|
||||
</label>
|
||||
<button
|
||||
@click="designMode.toggleDesignMode"
|
||||
:style="{
|
||||
width: '40px',
|
||||
height: '24px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: designModeOn ? '#2563eb' : '#e2e8f0',
|
||||
border: 'none',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
}"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
left: designModeOn ? '18px' : '2px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'white',
|
||||
transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.2)',
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Edit Panel -->
|
||||
<div
|
||||
v-if="designModeOn && selectedEl"
|
||||
style="
|
||||
pointer-events: auto;
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #0f172a">
|
||||
Edit Element
|
||||
</h3>
|
||||
<code
|
||||
style="
|
||||
font-size: 12px;
|
||||
background-color: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #64748b;
|
||||
"
|
||||
>
|
||||
{{ getElementTag }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 16px">
|
||||
<!-- Background -->
|
||||
<div>
|
||||
<label
|
||||
style="
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
"
|
||||
>
|
||||
Background
|
||||
</label>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px">
|
||||
<button
|
||||
v-for="preset in TAILWIND_PRESETS.bgColors"
|
||||
:key="preset.value"
|
||||
@click="modifyClass(preset.value)"
|
||||
:title="preset.label"
|
||||
:style="{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: preset.color,
|
||||
boxShadow: hasClass(preset.value) ? '0 0 0 2px #3b82f6' : 'none',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Color -->
|
||||
<div>
|
||||
<label
|
||||
style="
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
"
|
||||
>
|
||||
Text Color
|
||||
</label>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px">
|
||||
<button
|
||||
v-for="preset in TAILWIND_PRESETS.textColors"
|
||||
:key="preset.value"
|
||||
@click="modifyClass(preset.value)"
|
||||
:title="preset.label"
|
||||
:style="{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#f8fafc',
|
||||
color: preset.color,
|
||||
boxShadow: hasClass(preset.value) ? '0 0 0 2px #3b82f6' : 'none',
|
||||
}"
|
||||
>
|
||||
A
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Padding -->
|
||||
<div>
|
||||
<label
|
||||
style="
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
"
|
||||
>
|
||||
Padding
|
||||
</label>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 4px">
|
||||
<button
|
||||
v-for="preset in TAILWIND_PRESETS.paddings"
|
||||
:key="preset.value"
|
||||
@click="modifyClass(preset.value)"
|
||||
:style="{
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: hasClass(preset.value) ? '#eff6ff' : 'white',
|
||||
color: hasClass(preset.value) ? '#1d4ed8' : '#64748b',
|
||||
borderColor: hasClass(preset.value) ? '#93c5fd' : '#e2e8f0',
|
||||
}"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rounded -->
|
||||
<div>
|
||||
<label
|
||||
style="
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
"
|
||||
>
|
||||
Rounded
|
||||
</label>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 4px">
|
||||
<button
|
||||
v-for="preset in TAILWIND_PRESETS.rounded"
|
||||
:key="preset.value"
|
||||
@click="modifyClass(preset.value)"
|
||||
:style="{
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: hasClass(preset.value) ? '#eff6ff' : 'white',
|
||||
color: hasClass(preset.value) ? '#1d4ed8' : '#64748b',
|
||||
borderColor: hasClass(preset.value) ? '#93c5fd' : '#e2e8f0',
|
||||
}"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding-top: 12px; border-top: 1px solid #f1f5f9">
|
||||
<button
|
||||
@click="designMode.resetModifications"
|
||||
style="
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #dc2626;
|
||||
background-color: #fef2f2;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
"
|
||||
>
|
||||
Reset All Modifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'info',
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const isVisible = ref(true);
|
||||
const isAnimatingOut = ref(false);
|
||||
|
||||
const getBorderColor = () => {
|
||||
switch (props.type) {
|
||||
case 'error':
|
||||
return '#ef4444';
|
||||
case 'success':
|
||||
return '#22c55e';
|
||||
case 'info':
|
||||
return '#3b82f6';
|
||||
default:
|
||||
return '#3b82f6';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
isAnimatingOut.value = true;
|
||||
setTimeout(() => {
|
||||
isVisible.value = false;
|
||||
emit('close');
|
||||
}, 300);
|
||||
}, props.duration);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
:class="['design-mode-toast', type, { 'animating-out': isAnimatingOut }]"
|
||||
:style="{
|
||||
background: 'white',
|
||||
color: '#333',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
border: '1px solid #eee',
|
||||
borderLeft: `4px solid ${getBorderColor()}`,
|
||||
minWidth: '200px',
|
||||
maxWidth: '400px',
|
||||
}"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-mode-toast {
|
||||
animation: toast-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.design-mode-toast.animating-out {
|
||||
animation: toast-out 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '../composables/useToast';
|
||||
import Toast from './Toast.vue';
|
||||
|
||||
const { toasts, remove } = useToast();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
style="
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
"
|
||||
>
|
||||
<Toast
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:message="toast.message"
|
||||
:type="toast.type"
|
||||
:duration="toast.duration"
|
||||
@close="remove(toast.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useDesignMode, createDesignMode } from './composables/useDesignMode';
|
||||
export { useToast } from './composables/useToast';
|
||||
export { default as DesignModeApp } from './DesignModeApp.vue';
|
||||
export { default as DesignModeUI } from './components/DesignModeUI.vue';
|
||||
export { default as ContextMenu } from './components/ContextMenu.vue';
|
||||
export { default as Toast } from './components/Toast.vue';
|
||||
export { default as ToastContainer } from './components/ToastContainer.vue';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createApp } from 'vue';
|
||||
import DesignModeApp from './DesignModeApp.vue';
|
||||
|
||||
const init = () => {
|
||||
const containerId = '__vite_plugin_design_mode__';
|
||||
if (document.getElementById(containerId)) return;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.id = containerId;
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Use Shadow DOM to isolate styles
|
||||
const shadowRoot = container.attachShadow({ mode: 'open' });
|
||||
const rootElement = document.createElement('div');
|
||||
shadowRoot.appendChild(rootElement);
|
||||
|
||||
// Create Vue app
|
||||
const app = createApp(DesignModeApp);
|
||||
app.mount(rootElement);
|
||||
};
|
||||
|
||||
function isInIframe() {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
init();
|
||||
}
|
||||
5
qiming-vite-plugin-design-mode/packages/client-vue/src/shims-vue.d.ts
vendored
Normal file
5
qiming-vite-plugin-design-mode/packages/client-vue/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user