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

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

View File

@@ -0,0 +1,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 stateAPI 上为 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>

View 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

@@ -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();
}

View 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;
}