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