feat: 编辑器添加预览功能

master
绝弹 2023-10-20 23:22:13 +08:00
parent 62026cedd2
commit 85c5e68db7
18 changed files with 301 additions and 55 deletions

2
.env
View File

@ -6,7 +6,7 @@ VITE_TITLE = 绝弹管理后台
# 网站副标题
VITE_SUBTITLE = 快速开发web应用的模板工具
# 接口前缀 说明:参见 axios 的 baseURL
VITE_API = http://127.0.0.1:3030/
VITE_API = https://nest.dev.juetan.cn/
# =====================================================================================
# 开发设置

View File

@ -5,7 +5,7 @@
<component v-else :is="Component"></component>
</router-view>
</a-config-provider>
<div v-if="false">
<div v-if="true">
<my-editor></my-editor>
</div>
</template>

View File

@ -1,4 +1,4 @@
import { CSSProperties, StyleValue } from "vue";
import { CSSProperties } from "vue";
export interface Font {
/**

View File

@ -1,6 +1,6 @@
import { defineBlocker } from "../../config";
import { Date } from "./interface";
import { font } from "../components/font";
import { Date } from "./interface";
import Option from "./option.vue";
import Render from "./render.vue";
@ -14,6 +14,7 @@ export default defineBlocker<Date>({
initial: {
id: "",
type: "date",
title: '',
x: 0,
y: 0,
w: 300,

View File

@ -11,4 +11,13 @@ const getBlockerRender = (type: string) => {
return BlockerMap[type].render;
};
export { BlockerMap, getBlockerRender };
const getTypeName = (type: string) => {
return BlockerMap[type].title;
};
const getIcon = (type: string) => {
return BlockerMap[type].icon;
};
export { BlockerMap, getBlockerRender, getIcon, getTypeName };

View File

@ -1,8 +1,8 @@
import { defineBlocker } from "../../config";
import Render from "./render.vue";
import Option from "./option.vue";
import { Text } from "./interface";
import { font } from "../components/font";
import { Text } from "./interface";
import Option from "./option.vue";
import Render from "./render.vue";
export default defineBlocker<Text>({
type: "text",
@ -14,6 +14,7 @@ export default defineBlocker<Text>({
initial: {
id: "",
type: "text",
title: "",
x: 0,
y: 0,
w: 300,

View File

@ -24,9 +24,7 @@
</a-form-item>
</div>
<a-divider></a-divider>
<div>
<font-option :data="data.params.fontCh"></font-option>
</div>
<font-option :data="data.params.fontCh"></font-option>
</template>
<script setup lang="ts">

View File

@ -1,8 +1,8 @@
import { defineBlocker } from "../../config";
import { font } from "../components/font";
import { Time } from "./interface";
import Option from "./option.vue";
import Render from "./render.vue";
import { font } from "../components/font";
export default defineBlocker<Time>({
type: "time",
@ -14,6 +14,7 @@ export default defineBlocker<Time>({
initial: {
id: "",
type: "time",
title: '',
x: 0,
y: 0,
w: 300,

View File

@ -1,8 +1,12 @@
<template>
<div>
<a-form-item label="组件名称">
<a-input v-model="data.title"></a-input>
</a-form-item>
<div class="flex gap-4">
<a-form-item label="左侧">
<a-input-number v-model="data.x" :min="0" :max="100">
<a-input-number v-model="data.x" :min="0" :max="container.width">
<template #prefix>
<a-tooltip content="固定水平方向">
<i
@ -15,7 +19,7 @@
</a-input-number>
</a-form-item>
<a-form-item label="顶部">
<a-input-number v-model="data.y" :min="0" :max="100">
<a-input-number v-model="data.y" :min="0" :max="container.height">
<template #prefix>
<a-tooltip content="固定垂直方向">
<i
@ -31,10 +35,10 @@
<div class="flex gap-4">
<a-form-item label="宽度">
<a-input-number v-model="data.w" :min="0" :max="100"> </a-input-number>
<a-input-number v-model="data.w" :min="0" :max="container.width"> </a-input-number>
</a-form-item>
<a-form-item label="高度">
<a-input-number v-model="data.h" :min="0" :max="100"> </a-input-number>
<a-input-number v-model="data.h" :min="0" :max="container.height"> </a-input-number>
</a-form-item>
</div>
@ -50,7 +54,7 @@
<script setup lang="ts">
import { PropType } from "vue";
import { Block } from "../config";
import { Block, ContextKey } from "../config";
import InputColor from "./InputColor.vue";
import InputImage from "./InputImage.vue";
@ -60,6 +64,8 @@ defineProps({
required: true,
},
});
const { container } = inject(ContextKey)!
</script>
<style scoped></style>

View File

@ -28,12 +28,12 @@
</div>
</div>
<a-spin :loading="loading" :dot="true" tip="加载中..." class="h-[450px] w-full">
<div class="h-[450px] grid grid-cols-5 grid-rows-2 items-start justify-between gap-4 mt-2">
<div class="h-[450px] grid grid-cols-5 grid-rows-2 items-start justify-between gap-2 mt-2">
<div
v-for="item in images"
:key="item.id"
:class="{ selected: selectedKeys.includes(item.id) }"
class="p-2 border border-transparent rounded"
class="p-1 border border-transparent rounded"
@click="onSelectedImage(item)"
>
<div class="w-full bg-gray-50 flex items-center justify-center">

View File

@ -1,17 +1,66 @@
export interface Block<T = any> {
/**
* ID
*/
id: string;
/**
*
*/
type: string;
/**
*
*/
title: string;
/**
*
*/
x: number;
/**
*
*/
y: number;
/**
*
*/
w: number;
/**
*
*/
h: number;
/**
*
*/
xFixed: boolean;
/**
*
*/
yFixed: boolean;
/**
*
*/
bgImage?: string;
/**
*
*/
bgColor?: string;
/**
*
*/
actived: boolean;
/**
*
*/
resizable: boolean;
/**
*
*/
draggable: boolean;
/**
*
*/
meta: Record<string, any>;
/**
*
*/
params: T;
}

View File

@ -2,14 +2,16 @@ import { InjectionKey, Ref } from "vue";
import { Block } from "./block";
import { Container } from "./container";
export interface Current {
block: Block | null;
rightPanelCollapsed: boolean;
}
export interface Context {
/**
*
*/
current: Ref<{
block: Block | null;
rightPanelCollapsed: boolean;
}>;
current: Ref<Current>;
/**
*
*/
@ -36,7 +38,10 @@ export interface Context {
*
*/
loadData: () => void;
/**
*
*/
preview: () => void;
}
export const ContextKey = Symbol('ContextKey') as InjectionKey<Context>;
export const ContextKey = Symbol("ContextKey") as InjectionKey<Context>;

View File

@ -4,7 +4,7 @@
<div class="h-13 bg-white border-b border-slate-200 z-10">
<panel-header></panel-header>
</div>
<div class="grid grid-cols-[auto_1fr_auto]">
<div class="grid grid-cols-[auto_1fr_auto] overflow-hidden">
<div class="h-full overflow-hidden bg-white shadow-[2px_0_6px_rgba(0,0,0,.05)] z-10">
<panel-left></panel-left>
</div>
@ -16,6 +16,7 @@
</div>
</div>
</div>
<appnify-preview v-model:visible="preview"></appnify-preview>
</a-modal>
</template>
@ -25,6 +26,9 @@ import PanelHeader from "./panel-header/index.vue";
import PanelLeft from "./panel-left/index.vue";
import PanelMain from "./panel-main/index.vue";
import PanelRight from "./panel-right/index.vue";
import AppnifyPreview from "./preview/index.vue";
const preview = ref(false);
/**
* 运行时上下文
@ -105,7 +109,15 @@ const setCurrentBlock = (block: Block | null) => {
const setContainerOrigin = () => {
container.value.x = 0;
container.value.y = 0;
container.value.zoom = 0.7;
const el = document.querySelector(".juetan-editor-container");
if (el) {
const { width, height } = el.getBoundingClientRect();
const wZoom = width / container.value.width;
const hZoom = height / container.value.width;
const zoom = Math.floor((wZoom > hZoom ? wZoom : hZoom) * 100) / 100;
// console.log(width, height, wZoom, hZoom, zoom);
container.value.zoom = zoom;
}
};
/**
@ -119,14 +131,17 @@ provide(ContextKey, {
setContainerOrigin,
loadData,
saveData,
preview() {
preview.value = true;
},
});
</script>
<style lang="less">
.ani-modal {
.muti-form-item .arco-form-item .arco-form-item-label {
color: #899;
font-size: 12px;
// color: #899;
// font-size: 12px;
line-height: 1;
}
.arco-modal-fullscreen {

View File

@ -1,14 +1,19 @@
<template>
<div class="h-full grid grid-cols-[auto_1fr]" :style="{ width: !collapsed ? '248px' : undefined }">
<div class="h-full grid grid-rows-[1fr_auto] border-r border-slate-200">
<a-menu :collapsed="true" :default-selected-keys="['0_0']">
<a-menu-item key="0_0">
<a-menu
:collapsed="true"
:default-selected-keys="['0_0']"
:selected-keys="[key]"
@menu-item-click="(k) => (key = k)"
>
<a-menu-item key="list">
<template #icon>
<i class="icon-park-outline-all-application"></i>
</template>
组件列表
</a-menu-item>
<a-menu-item key="0_1">
<a-menu-item key="data">
<template #icon>
<i class="icon-park-outline-rss"></i>
</template>
@ -36,16 +41,16 @@
</div>
</div>
<div v-show="!collapsed">
<ul class="list-none px-2 grid gap-2" @dragstart="onDragStart" @dragover="onDragOver">
<ul v-show="key === 'list'" class="list-none px-2 grid gap-2" @dragstart="onDragStart" @dragover="onDragOver">
<li
v-for="item in blocks"
v-for="item in blockList"
:key="item.type"
:draggable="true"
:data-type="item.type"
class="flex items-center justify-between gap-2 bg-gray-100 text-gray-500 px-2 py-1 rounded cursor-move"
class="flex items-center justify-between gap-2 bg-gray-100 text-gray-500 px-2 py-1 rounded border border-transparent cursor-move hover:bg-brand-50 hover:text-brand-500 hover:border-dashed hover:border-brand-500"
>
<div class="">
<i class="text-base text-gray-500" :class="item.icon"></i>
<i class="text-base" :class="item.icon"></i>
</div>
<div class="flex-1 leading-0">
<div class="">
@ -54,25 +59,70 @@
</div>
</li>
</ul>
<ul v-show="key === 'data'" class="list-none px-2 grid gap-2">
<li
v-for="item in blocks"
:key="item.id"
class="group h-8 w-full overflow-hidden grid grid-cols-[auto_1fr_auto] items-center gap-2 bg-gray-100 text-gray-500 px-2 py-1 rounded border border-transparent"
:class="{
'!bg-brand-50': current.block === item,
'!text-brand-500': current.block === item,
'!border-brand-300': current.block === item,
}"
@click="setCurrentBlock(item)"
>
<div class="">
<i class="text-base" :class="getIcon(item.type)"></i>
</div>
<div class="w-full select-none truncate">
{{ item.title }}
</div>
<div class="w-4">
<i
class="!hidden !group-hover:inline-block text-gray-400 hover:text-gray-700 icon-park-outline-delete !text-xs"
@click="onDeleteBlock($event, item)"
></i>
</div>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { BlockerMap } from "../blocks";
import { BlockerMap, getIcon } from "../blocks";
import { Block, ContextKey } from "../config";
const blocks = Object.values(BlockerMap);
const { blocks, current, setCurrentBlock } = inject(ContextKey)!;
const blockList = Object.values(BlockerMap);
const collapsed = ref(false);
const key = ref("list");
/**
* 拖拽开始时设置数据
*/
const onDragStart = (e: DragEvent) => {
console.log("start");
e.dataTransfer?.setData("type", (e.target as HTMLElement).dataset.type!);
};
/**
* 拖拽时阻止默认行为
*/
const onDragOver = (e: Event) => {
console.log("over");
e.preventDefault();
};
/**
* 删除组件
*/
const onDeleteBlock = async (e: Event, block: Block) => {
e.preventDefault();
const index = blocks.value.indexOf(block);
if (index > -1) {
blocks.value.splice(index, 1);
}
};
</script>
<style scoped></style>

View File

@ -13,6 +13,7 @@
:isActive="data.actived"
:isResizable="data.resizable"
:style="blockStyle"
:class="'resizer'"
@dragging="onItemDragOrResize"
@resizing="onItemDragOrResize"
@activated="setCurrentBlock(data)"
@ -57,4 +58,25 @@ const onItemDragOrResize = (rect: any) => {
};
</script>
<style scoped></style>
<style lang="less" scoped>
.resizer {
outline: 1px dashed #ccc;
&:hover {
outline-color: rgb(var(--primary-6));
background-color: rgba(var(--primary-1), .5);
}
&.active {
&::before {
outline-style: solid;
outline-color: rgb(var(--primary-6));
background-color: rgba(var(--primary-1), .5);
}
}
::v-deep .vdr-stick {
border-color: rgb(var(--primary-6));
}
:deep(.content-container) {
overflow: hidden;
}
}
</style>

View File

@ -21,7 +21,7 @@
</span>
<span class="text-gray-400 text-xs mr-2">
组件
<span class="inline-block w-6 text-gray-700">{{ blocks.length }} </span>
<span class="inline-block w-8 text-gray-700">{{ blocks.length }} </span>
</span>
<a-tooltip content="自适应比例" position="bottom">
<a-button type="text" @click="setContainerOrigin">
@ -31,7 +31,7 @@
</a-button>
</a-tooltip>
<a-tooltip content="预览" position="bottom">
<a-button type="text">
<a-button type="text" @click="preview">
<template #icon>
<i class="icon-park-outline-play text-base !text-gray-600"></i>
</template>
@ -78,7 +78,7 @@ import InputImage from "../../components/InputImage.vue";
import { ContextKey } from "../../config";
import AniTexter from "./texter.vue";
const { container, blocks, current, setContainerOrigin } = inject(ContextKey)!;
const { container, blocks, current, preview, setContainerOrigin } = inject(ContextKey)!;
</script>
<style scoped></style>

View File

@ -4,7 +4,7 @@
<ani-header :container="container"></ani-header>
</div>
<div class="h-full w-full overflow-hidden p-4">
<div class="dd1 w-full h-full flex items-center justify-center overflow-hidden relative bg-slate-50">
<div class="juetan-editor-container w-full h-full flex items-center justify-center overflow-hidden relative bg-slate-50">
<div
class="relative"
:style="containerStyle"
@ -23,14 +23,18 @@
</template>
<script setup lang="ts">
import { cloneDeep } from "lodash-es";
import { CSSProperties } from "vue";
import { BlockerMap } from "../blocks";
import { ContextKey } from "../config";
import AniBlock from "./components/block.vue";
import AniHeader from "./components/header.vue";
import { cloneDeep } from "lodash-es";
const { blocks, container, setCurrentBlock } = inject(ContextKey)!;
/**
* 清空当前组件
*/
const onClick = (e: Event) => {
if (e.target === e.currentTarget) {
setCurrentBlock(null);
@ -47,12 +51,18 @@ const position = ref({
mouseY: 0,
});
/**
* 拖拽容器开始
*/
const onMouseDown = (e: MouseEvent) => {
isStart.value = true;
position.value.startX = e.offsetX;
position.value.startY = e.offsetY;
};
/**
* 拖拽容器移动
*/
const onMouseMove = (e: MouseEvent) => {
if (!isStart.value) {
return;
@ -62,6 +72,9 @@ const onMouseMove = (e: MouseEvent) => {
container.value.y += (e.offsetY - position.value.startY) * scale;
};
/**
* 拖拽容器结束
*/
const onMouseUp = () => {
isStart.value = false;
};
@ -74,6 +87,9 @@ onUnmounted(() => {
window.removeEventListener("mouseup", onMouseUp);
});
/**
* 容器样式
*/
const containerStyle = computed(() => {
const { width, height, bgColor, bgImage, zoom, x, y } = container.value;
return {
@ -86,9 +102,12 @@ const containerStyle = computed(() => {
transform: `translate3d(${x}px, ${y}px, 0) scale(${zoom})`,
// transform: `matrix(${zoom}, 0, 0, ${zoom}, ${x}, ${y})`,
// transformOrigin: "0 0",
} as any;
} as CSSProperties;
});
/**
* 接收拖拽并新增组件
*/
const onDragDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
@ -98,18 +117,22 @@ const onDragDrop = (e: DragEvent) => {
return;
}
const blocker = BlockerMap[type];
const currentIds = blocks.value.map((item) => item.id);
const maxId = Math.max(...currentIds.map((item) => parseInt(item)));
const currentIds = blocks.value.map((item) => Number(item.id));
const maxId = currentIds.length ? Math.max.apply(null, currentIds) : 0;
const id = (maxId + 1).toString();
const title = `${blocker.title}${id}`;
blocks.value.push({
...cloneDeep(blocker.initial),
id,
title,
x: e.offsetX,
y: e.offsetY,
});
};
/**
* 滚轮缩放容器
*/
const onMouseWheel = (e: WheelEvent) => {
e.preventDefault();
const prezoom = container.value.zoom;
@ -138,7 +161,7 @@ const onMouseWheel = (e: WheelEvent) => {
</script>
<style scoped>
.dd1 {
.juetan-editor-container {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
}
</style>

View File

@ -1,13 +1,79 @@
<template>
<div>
<div class="h-0 overflow-hidden">
<div ref="el" class="bg-white w-screen h-screen select-none">
<div
v-if="visible"
:style="{
position: 'relative',
width: `${container.width}px`,
height: `${container.height}px`,
backgroundImage: `url(${container.bgImage})`,
backgroundColor: container.bgColor,
backgroundSize: '100% 100%',
}"
>
<div
v-for="block in blocks"
:key="block.id"
:style="{
position: 'absolute',
left: `${block.x}px`,
top: `${block.y}px`,
width: `${block.w}px`,
height: `${block.h}px`,
backgroundImage: `url(${block.bgImage})`,
backgroundColor: block.bgColor,
backgroundSize: '100% 100%',
}"
>
<component :is="BlockerMap[block.type].render" :data="block" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Message } from "@arco-design/web-vue";
import { useFullscreen } from "@vueuse/core";
import { BlockerMap } from "../blocks";
import { ContextKey } from "../config";
const { container, blocks } = inject(ContextKey)!;
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:visible"]);
const el = ref<HTMLElement | null>(null);
const { enter, isFullscreen, isSupported } = useFullscreen(el);
watch(
() => isFullscreen.value,
() => {
if (!isFullscreen.value) {
emit("update:visible", false);
}
}
);
watch(
() => props.visible,
(value) => {
if (!value) {
return;
}
if (!isSupported.value) {
Message.warning("抱歉,您的浏览器不支持全屏功能!");
return;
}
enter();
}
);
</script>
<style scoped>
</style>
<style scoped></style>