first edition

pull/1/head
zestack 1 year ago
commit ddf6148d14
  1. 1
      .gitattributes
  2. 24
      .gitignore
  3. 3
      .vscode/extensions.json
  4. 18
      README.md
  5. 22
      eslint.config.js
  6. 13
      index.html
  7. 5221
      package-lock.json
  8. 27
      package.json
  9. 1
      public/vite.svg
  10. 296
      src/App.vue
  11. 1
      src/assets/vue.svg
  12. 229
      src/engineer/components/Activity.vue
  13. 77
      src/engineer/components/Builder.vue
  14. 335
      src/engineer/components/Canvas.vue
  15. 257
      src/engineer/components/Configurator.vue
  16. 101
      src/engineer/components/Designer.vue
  17. 126
      src/engineer/components/Engineer.vue
  18. 138
      src/engineer/context.ts
  19. 23
      src/engineer/index.ts
  20. 175
      src/engineer/render.ts
  21. 558
      src/engineer/types.ts
  22. 14
      src/engineer/utils/align.ts
  23. 27
      src/engineer/utils/background.ts
  24. 49
      src/engineer/utils/border.ts
  25. 19
      src/engineer/utils/gap.ts
  26. 17
      src/engineer/utils/hash.ts
  27. 12
      src/engineer/utils/index.ts
  28. 27
      src/engineer/utils/insets.ts
  29. 25
      src/engineer/utils/is.ts
  30. 23
      src/engineer/utils/object.ts
  31. 30
      src/engineer/utils/radii.ts
  32. 37
      src/engineer/utils/shadow.ts
  33. 18
      src/engineer/utils/size.ts
  34. 7
      src/engineer/utils/unit.ts
  35. 22
      src/engineer/views/AudioView.vue
  36. 38
      src/engineer/views/ImageView.vue
  37. 17
      src/engineer/views/TextView.vue
  38. 116
      src/engineer/views/View.vue
  39. 4
      src/engineer/views/index.ts
  40. 5
      src/main.ts
  41. 40
      src/style.less
  42. 6
      src/vite-env.d.ts
  43. 25
      tsconfig.json
  44. 10
      tsconfig.node.json
  45. 7
      vite.config.ts

1
.gitattributes vendored

@ -0,0 +1 @@
* text=auto eol=lf

24
.gitignore vendored

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

@ -0,0 +1,18 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support For `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

@ -0,0 +1,22 @@
import antfu from '@antfu/eslint-config'
export default await antfu({
stylistic: {
indent: 2,
quotes: 'single',
},
typescript: true,
vue: true,
rules: {
'vue/singleline-html-element-content-newline': 'off',
'vue/html-self-closing': ['error', {
html: {
void: 'always',
normal: 'always',
component: 'always',
},
svg: 'always',
math: 'always',
}],
},
})

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5221
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,27 @@
{
"name": "data-driven-design",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"vue": "^3.3.8",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@antfu/eslint-config": "^2.1.1",
"@types/node": "^20.9.4",
"@vitejs/plugin-vue": "^4.5.0",
"eslint": "^8.54.0",
"less": "^4.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"vue-tsc": "^1.8.22"
}
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,296 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Block, Module } from './engineer'
import DddBuilder, { hash } from './engineer'
let nextId = Date.now()
const blocks = ref<Block[]>([
{
vid: hash(`${++nextId}`),
mid: '',
theme: {
radius: 24,
color: 'yellow',
},
children: {
type: 'text',
key: 'textName',
},
},
{
vid: hash(`${++nextId}`),
mid: '',
theme: {
color: 'pink',
margin: 12,
flexible: true,
},
children: {
type: 'image',
key: 'imageKey',
link: 'linkKey',
radius: 24,
},
},
{
vid: hash(`${++nextId}`),
mid: '',
theme: {
color: '#fafbfc',
margin: 12,
padding: 24,
mainAlign: 'center',
crossAlign: 'center',
flexible: true,
},
children: {
type: 'audio',
key: 'audioKey',
title: 'title',
},
},
{
vid: hash(`${++nextId}`),
mid: '',
theme: {
radius: 24,
color: 'teal',
},
children: [
{
theme: {
flexible: true,
gap: 12,
crossAlign: 'center',
},
children: [
{
theme: {
padding: 12,
margin: 12,
grow: 1,
},
children: {
vid: hash(`${++nextId}`),
theme: {
fontSize: 32,
},
children: 'left',
},
},
{
vid: hash(`${++nextId}`),
theme: {
width: 'auto',
padding: {
vertical: 4,
horizontal: 12,
},
color: 'pink',
radius: 16,
},
children: 'right222',
},
{
theme: {
width: 48,
},
},
],
},
],
},
...Array.from(Array.from({ length: 3 })).map<Block>((_, i) => ({
vid: hash(`${++nextId}`),
mid: '',
children: [
{
vid: hash(`${++nextId}`),
theme: {
fontSize: 22,
},
children: `测试${i}`,
},
],
})),
])
const categories = ref([
{ icon: 'trash', text: '媒体' },
{ icon: 'trash', text: '图表' },
{ icon: 'trash', text: '商品' },
{ icon: 'trash', text: '功能' },
{ icon: 'trash', text: '素材' },
].map(c => ({
...c,
modules: Array.from(Array.from({ length: 102 })).map<Module>((_, i) => ({
vid: hash(`${++nextId}`),
mid: hash(`${++nextId}`),
title: `${c.text}${i + 1}`,
maxReferenceCount: -1,
referenceCount: 0,
image: undefined,
configs: [],
children: [
{
vid: hash(`${++nextId}`),
theme: {
height: 68,
width: 375,
border: { color: '#eee' },
},
children: [
String(`${c.text}${i + 1}`),
],
},
],
})),
})))
categories.value.unshift({
icon: 'trash',
text: '基础',
modules: [
{
vid: hash(`${++nextId}`),
mid: hash(`${++nextId}`),
title: '轮播图',
maxReferenceCount: -1,
referenceCount: 0,
image: undefined,
configs: [
{
type: 'list',
field: 'items',
label: '',
help: '最多可添加10张图片,建议宽度750px;鼠标拖拽左侧圆点可调整图片顺序',
addable: true,
configs: [
{
type: 'image',
field: 'image',
//
inlines: [
{
type: 'text',
field: 'title',
label: '标题',
help: '选填,不超过 4 个字',
},
{
type: 'text',
field: 'link',
label: '链接',
// help: '', // "${label}"
},
],
label: '图片',
required: true,
},
],
},
{
type: 'object',
field: 'indicator',
label: '指示器',
configs: [
{
type: 'mark',
field: 'style',
label: '指示器样式',
values: [
{ label: '圆形', value: 'circle' },
{ label: '直线', value: 'line' },
{ label: '数字', value: 'number' },
],
},
{
type: 'mark',
field: 'position',
label: '指示器位置',
values: [
{ label: '居左', value: 'left' },
{ label: '居中', value: 'center' },
{ label: '居右', value: 'right' },
],
},
{
type: 'color',
field: 'color',
label: '指示器颜色',
},
],
},
{
type: 'object',
field: 'background',
label: '背景',
configs: [
{
type: 'bool',
field: 'enabled',
label: '是否显示背景色',
},
{
type: 'background',
field: 'value',
// image
//
features: ['color', 'gradient'],
label: '背景',
},
],
},
],
theme: {
height: 200,
padding: {
horizontal: 12.0,
},
color: 'cyan',
},
children: [
{
vid: hash(`${++nextId}`),
theme: {
width: '100%',
height: '100%',
color: 'white',
radius: 12.0,
textAlign: 'center',
},
children: '内容',
},
],
},
],
})
const data = ref<Record<string, Record<string, unknown>>>({
[blocks.value[0].vid]: {
textName: '测试 2222',
},
[blocks.value[1].vid]: {
imageKey: 'https://www.w3schools.com/css/paris.jpg',
linkKey: 'https://docs.taro.zone/canIUse/',
},
[blocks.value[2].vid]: {
audioKey: 'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
title: '啊哈哈',
},
})
</script>
<template>
<DddBuilder
:categories="categories"
:blocks="blocks"
:sequence="nextId"
:sources="data"
/>
</template>
<style scoped>
</style>

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@ -0,0 +1,229 @@
<script setup lang="ts">
import { ref, unref } from 'vue'
import Draggable from 'vuedraggable'
import type { Block, Module } from '../types'
import { useContext } from '../context'
import { hash } from '../utils'
defineOptions({
name: 'DddActivity',
})
const currentTab = ref(0)
const ctx = useContext()
// ID
function clone(v: any): any {
if (v == null || typeof v != 'object')
return v
if (Array.isArray(v))
return v.slice().map(clone)
const s = Object.create(null)
for (const [key, val] of Object.entries(v)) {
if (key === 'vid' && typeof val === 'string' && val.length === 8)
s[key] = hash(`${++ctx.nextId}`)
else
s[key] = clone(val)
}
return s
}
function cloneModule(src: Module): Block {
// eslint-disable-next-line unused-imports/no-unused-vars
const { maxReferenceCount, referenceCount, image, configs, ...attrs } = unref(src)
return clone(attrs)
}
</script>
<template>
<div class="ddd-activity">
<div class="ddd-activity-header">
<slot name="header" />
</div>
<div class="ddd-activity-content">
<div class="ddd-activity-tabs">
<div
v-for="(c, i) in ctx.categories"
:key="i"
class="ddd-activity-tabs-item"
:class="{ 'is-active': currentTab === i }"
@click.stop="currentTab = i"
>
<!-- <Icon :name="c.icon" /> -->
<span>{{ c.text }}</span>
</div>
</div>
<template v-for="(cat, idx) in ctx.categories" :key="idx">
<Draggable
v-show="currentTab === idx"
class="ddd-activity-tabs-content"
:list="cat.modules"
:group="{ name: 'modules', pull: 'clone' }"
:clone="cloneModule"
:component-dat="{ sort: false }"
:sort="false"
item-key="id"
>
<template #item="{ element }">
<div class="ddd-activity-module">
<img v-if="element.image" class="ddd-activity-module-image" :src="element.image" />
<div v-else class="ddd-activity-module-image">{{ element.title }}</div>
<div class="ddd-activity-module-title">{{ element.title }}</div>
<span class="ddd-activity-module-tips">释放鼠标将组件添加到此处</span>
</div>
</template>
</Draggable>
</template>
</div>
<div class="ddd-activity-footer">
<slot name="footer" />
</div>
</div>
</template>
<style lang="less" scoped>
.ddd-activity {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
height: 100%;
overflow: hidden;
font-size: 13px;
&-header,
&-footer {
width: 100%;
flex-shrink: 0;
}
&-content {
flex: 1;
display: flex;
align-items: stretch;
overflow: hidden;
}
&-tabs {
position: relative;
flex-shrink: 0;
width: 64px;
padding: 12px 0;
&::after {
z-index: 0;
position: absolute;
top: 0;
bottom: 0;
right: 0;
content: "";
border-right: 1px solid #ddd;
}
&-item {
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 64px;
height: 64px;
user-select: none;
position: relative;
cursor: pointer;
gap: 2px;
transition: all 200ms;
&::after {
position: absolute;
content: "";
top: 0;
right: 0;
bottom: 0;
width: 2px;
background: transparent;
transition: all 200ms;
}
&:hover {
background: rgba(#000, 0.08);
}
&.is-active {
color: blue;
background: rgba(2, 86, 255, 0.1);
&::after {
background: blue;
}
}
}
&-content {
padding: 12px;
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-items: flex-start;
align-content: flex-start;
overflow-y: auto;
}
}
&-module {
//
&:not(.is-ghost) {
width: 100px;
border: 1px solid #eee;
user-select: none;
display: flex;
flex-direction: column;
text-align: center;
background: #fff;
}
&-image {
height: 68px;
width: 98px;
line-height: 68px;
color: #eee;
font-size: 24px;
overflow: hidden;
}
&-title {
height: 24px;
line-height: 24px;
background: #eee;
font-size: 13px;
}
&-tips {
display: none;
}
//
//
&.is-ghost {
background: rgba(0, 0, 255, 0.1);
color: blue;
padding: 8px;
text-align: center;
border: 1px dashed blue;
font-size: 12px;
}
&.is-ghost &-image,
&.is-ghost &-title {
display: none;
}
&.is-ghost &-tips {
display: block;
}
}
}
</style>

@ -0,0 +1,77 @@
<script setup lang="ts">
import type { UnwrapNestedRefs } from 'vue'
import { provide, reactive, unref } from 'vue'
import type { Block, Category } from '../types'
import type { EngineContext, Exported, PageConfig } from '../context'
import { contextKey, parentViewIdKey, provideBlockId } from '../context'
import DddEngineer from './Engineer.vue'
defineOptions({
name: 'DddBuilder',
})
const props = defineProps<{
page?: PageConfig
categories: Category[]
blocks: Block[]
sequence?: number
sources?: Record<string, Record<string, unknown>>
}>()
let ctx: UnwrapNestedRefs<EngineContext>
// eslint-disable-next-line prefer-const
ctx = reactive<EngineContext>({
target: 'routine',
activeBlockId: undefined,
activeViewId: undefined,
draggingViewId: undefined,
page: props.page ?? {
fullscreen: false,
brightness: 'light',
safeAreaInsetTop: true,
safeAreaInsetBottom: true,
backgroundColor: '#f1f1f1',
header: {
enabled: true,
custom: undefined,
title: 'PageTitle',
centerTitle: false,
height: 44,
color: '#ffffff',
textColor: '#000000',
bordered: true,
borderColor: '#ddd',
},
footer: {
enabled: false,
height: 44,
color: '#ffffff',
textColor: '#000000',
bordered: true,
borderColor: '#ddd', // 'rgba(51,51,51,.1)',
view: undefined,
},
},
canvas: {
focused: false,
scale: 1,
},
categories: props.categories!,
blocks: props.blocks,
nextId: props.sequence ?? Date.now(),
sources: props.sources ?? Object.create(null),
export(): Exported {
const attrs = unref(ctx.page)
const data = unref(ctx.sources)
return { attrs, data }
},
})
provideBlockId('')
provide(parentViewIdKey, '')
provide(contextKey, ctx)
</script>
<template>
<DddEngineer />
</template>

@ -0,0 +1,335 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import Draggable from 'vuedraggable'
import { useContext } from '../context'
import { RenderBlock } from '../render'
import { DddView } from '../views'
defineOptions({
name: 'DddCanvas',
})
// const emit = defineEmits<{
// (event: 'add', data: AddEventInfo): void
// (event: 'swap', data: SwapEventInfo): void
// }>()
// FIXME: 使 key
const key = ref(0)
const ctx = useContext()
const headerColor = computed(() => ctx.page.header.color ?? '#fff')
const headerTextColor = computed(() => ctx.page.header.textColor ?? '#000')
const headerBorderColor = computed(() => ctx.page.header.borderColor ?? 'rgba(51,51,51,.1)')
const headerHeight = computed(() => `${ctx.page.header.height ?? 44}px`)
const footerColor = computed(() => ctx.page.footer.color ?? '#fff')
const footerTextColor = computed(() => ctx.page.footer.textColor ?? '#000')
const footerBorderColor = computed(() => ctx.page.footer.borderColor ?? 'rgba(51,51,51,.1)')
const footerHeight = computed(() => `${ctx.page.footer.height ?? 44}px`)
const backgroundColor = computed(() => {
return ctx.page.backgroundColor
})
const measureLine = computed(() => {
return `${1 / ctx.canvas.scale}px`
})
// interface EmulatorChangedEvent {
// added?: AddEventInfo
// moved?: SwapEventInfo
// }
//
// const handleDraggingOver = (e: EmulatorChangedEvent): void => {
// if (e.moved) {
// const {oldIndex, newIndex, element} = e.moved
// emit('swap', {oldIndex, newIndex, element})
// }
// if (e.added) {
// const {newIndex, element} = e.added
// emit('add', {newIndex, element})
// }
// }
interface DraggableEvent {
oldDraggableIndex: number
newDraggableIndex: number
}
function handleDragStart(e: DraggableEvent): void {
ctx.draggingViewId = ctx.blocks[e.oldDraggableIndex].vid
}
function handleDragEnd(): void {
ctx.draggingViewId = undefined
}
function handleDragAdd(e: DraggableEvent): void {
const block = ctx.blocks[e.newDraggableIndex]
ctx.activeViewId = block.vid
ctx.activeBlockId = block.vid
ctx.hoverViewId = '#canvas'
ctx.configurator = 'block'
key.value++
}
function handleCanvasClick(e: MouseEvent): void {
if ((e.target as HTMLElement).classList.contains('ddd-canvas')) {
ctx.canvas.focused = true
ctx.activeViewId = undefined
ctx.activeBlockId = undefined
ctx.configurator = 'page'
}
}
</script>
<template>
<div
class="ddd-canvas"
:class="{
'is-focused': ctx.canvas.focused,
'is-active': ctx.activeViewId === '#canvas',
'is-hover': ctx.hoverViewId === '#canvas',
}"
>
<!-- 中间自定义内容 -->
<!-- @change="handleDraggingOver" -->
<Draggable
:key="key"
v-model="ctx.blocks"
class="ddd-canvas-viewer"
:animation="200"
:disabled="false"
:component-data="{
tag: 'div',
type: 'transition-group',
}"
:group="{
name: 'emulator',
put: 'modules',
}"
ghost-class="is-ghost"
item-key="id"
@click="handleCanvasClick"
@start="handleDragStart"
@end="handleDragEnd"
@add="handleDragAdd"
@change="key++"
>
<template #header>
<div v-show="ctx.page.safeAreaInsetTop && !ctx.page.fullscreen" class="ddd-canvas-safe-area-inset-top" />
<div v-show="ctx.page.header.enabled" class="ddd-canvas-safe-area-inset-header" />
</template>
<template #footer>
<div v-show="ctx.page.footer.enabled" class="ddd-canvas-safe-area-inset-footer" />
<div v-show="ctx.page.safeAreaInsetBottom && !ctx.page.fullscreen" class="ddd-canvas-safe-area-inset-bottom" />
</template>
<template #item="{ element }">
<RenderBlock class="ddd-view" :block="element" />
</template>
</Draggable>
<!-- 页面头部 -->
<DddView
v-show="ctx.page.header.enabled"
vid="#header"
class="ddd-canvas-header"
:class="{ 'is-bordered': ctx.page.header.bordered ?? true }"
>
<div v-show="ctx.page.safeAreaInsetTop && !ctx.page.fullscreen" class="ddd-canvas-safe-area-inset-top" />
<div
class="ddd-canvas-header-title"
:style="{ textAlign: ctx.page.header.centerTitle ? 'center' : undefined }"
v-text="ctx.page.header.title"
/>
</DddView>
<!-- 页面底部 -->
<DddView
v-show="ctx.page.footer.enabled"
vid="#footer"
class="ddd-canvas-footer"
:class="{ 'is-bordered': ctx.page.footer.bordered ?? true }"
>
<div class="ddd-canvas-footer-content" />
<div v-show="ctx.page.safeAreaInsetBottom && !ctx.page.fullscreen" class="ddd-canvas-safe-area-inset-bottom" />
</DddView>
<!-- 顶部状态栏小程序胶囊按钮底部操作指示器 -->
<template v-if="!ctx.page.fullscreen">
<div class="ddd-canvas-statusbar">
<img style="height:20px" alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXcAAAAUCAYAAAB2132+AAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAABd6ADAAQAAAABAAAAFAAAAABeOGisAAAGY0lEQVR4Ae2ZCWwVVRSGX6VaRItogShW8xCsilZwQ8QqQRQjilaLEhG1uEWIS9QgrmlFUYILwaIxRuKCCrhgVcANbIjGqMiSWGrSsFTEJSgYLUtww+9v58ab6fS9tjxbXt75k6/33jMz9837e++ZM20sZjIHzAFzwBwwB8wBc8AcMAfMAXPAHDAHzAFzwBwwB8wBc8AcMAfMgVQ4kJWKSWwOc8AcMAfMgUgHehItiDzS9mAtl25KdvleyU5o5fFizt8VoH5z6sWB+2Ax/A7rgnYJbRkcCiZzIAcLepgN5sBuODCBa68OXa9kOwMWwUTw8+DZjGfD63AxOB1C5yYY5AJBeyttbijmhoV0Stwgha3m1NwJ5X+phCe28OAA7zy/74VjMxlUw4MwDPQw6Az/wFlQDt/A02DKTAey+dpPwTZQhbIGToJE0lvoAqj3TtL6vgF2wGgvHtWdS1Br8cLQQRePWs9jgmtUkJj2LAdO5HbK4VGIg9P+dN6BTnAvnAFTQeoPr8AyeBymwQXQBRaC1uLtMAokFaH54K85xZ2U314FrWf1U0H3YM4i2v9Vq5hdG2Jl8CnlwVgx9X3dxWAzaMP+DFUgcy+D00Gb735QXCbKsC2gX4IpsxyYxNfdCefBwfAwbARV8s1pIgf0Fug2mtbNBlgNWo+JkntXjm+HtaCKzZdL7s/6waD/Ca3mLos4ZqGOdUB5ZTrUgP/7KWH8OTgNoaO8pEKgAlyip9twXSWtis4nFUADYVZDLxZ7glbrszkp50laI6ni14YZYzE3dzBs2ugL7Y70pJOiqprGI//93EpX1ZUqdG20obACjoQR0AdkuuKXg879O4DGlEEOnMl3rYL34CfQJlWVNBwOhCWgdeI0mM7N8JALBO0dtC2pcFSJ7QBVZSOhG/jShlKVfoAXLKSvjb7di1l3z3HgNW7lNvgydEt9GasoddID/SBQRRx1rID4pzAMxsMj8BYoXymnaX22p8JrUw+rSLUmuRczg3v6qN9azeQCGZUL6+FreAm0Ie+BKTAHlPD1Gq7Xp6PAlHkO6LW4H+gBL53a2MQOp9XfPlVJnRLE8mi1bq4DvRE6qTBQFa41m0xXcsJ8WARK1peCLyUDrdmrvKA2eiXUezHr7vkOxLlFveE5fU9Ha2Q/iMNv4PQdHcX/gHOhB0yGBaAHh4oO5SkVp/tAR0hFzqCoD06U3JXAy8Al8gHeBH7fCyft/hKc8QHtcSBT9eRUZaRWVf0J8CFIWxob+5lhDrzJ9z0MPgJtJhUG2mDZUANxeAyU/F8EJWa3Zui2SnpgDIF58CdUgpJ9WLMIKKFLKlDGgmKm9HJAD28VBE770tHvXYlcx7qDkxK7Ck1pI2gtLgXlLj0UtA6+glGwHDpDR0hvHk2UKLnr1aMc1KZaquC1KdX2hf5BewztC2AVOyZksPRWVwpd4RyYBFqrP4D0LagQuBZUDLwM2nD5oPPU16Ztia7gpCy4CKaD5iiCOPiazaAPDAVdo8JjMZjSywGtnbh3y+rXwl8QdUzFRFi3EKgAFQEPwDXwBoyE9pb+nPhF1IcmSu5R56cyVspk2jDayKrkq0GvPePAlNkOHM3X10YbCKeB/q6ZDSvAV28GvUDVk9bRZOgS9FUwtETaoGtABcWxwQWq4MYGfddspqOqfgKMh+dBDxhTejnwLrc7GFRYaq3oH5OfgfQ26MGdA/lwPbhjdBukNbkatkIdHA97Qz+og/bWVD5Qa7OJOjK562ZcxaWKXfyooCnjHcjDgY9hBOg1+W6ogrXQE5TIVSVNAR13qKLaFoyV8JPpZE7QursRhnvMo6+kH9ZzBEpAbwZK7qb0c2A9t3wnLAetp8JgTNNQbK6k3QA1sArmgC+tlWeCwHxaFaTVsA6WQZSWRgXbGAvPFZnYk82tL7YL1ErFoLFQX2rJOY1nNv9TDxgZbTIHfAcqGGjhar0pUatCl+KwCUohrHEE6kPBbow1x+hQXMMZoLeCThp4UqLXNfpH7lzQg0bSWq2D98FJ15e5gbVp40Aud3pEM3ertaYiIiytk97hYILxGI4VJTje1kOa85JkF2clO8GOmwMd6IA2Ux4omZvMgXRzIIcbPh8KUnzjtcy3EHameF6bzhwwB8wBc8AcMAfMAXPAHDAHzAFzwBwwB8wBc8AcMAfMAXPAHDAHIhz4FwO/QgbhZ4FRAAAAAElFTkSuQmCC" />
</div>
<div v-if="ctx.target === 'routine'" class="ddd-canvas-routine-button">
<svg width="87" height="32" viewBox="0 0 87 32" xmlns="http://www.w3.org/2000/svg">
<rect width="87" height="32" rx="16" x="0" y="0" fill="rgba(0,0,0,0.3)" />
<rect id="rect5" width="0.5" height="18.5" fill="rgba(255,255,255,0.6)" x="43.5" y="6.5" />
<path d="M 19.5,16.25 A 3.25,3.25 0 1 1 22.75,19.5 3.25,3.25 0 0 1 19.5,16.25 Z M 28,16.5 a 2,2 0 1 1 2,2 2,2 0 0 1 -2,-2 z m -14.5,0 a 2,2 0 1 1 2,2 2,2 0 0 1 -2,-2 z" fill="#ffffff" />
<path d="m 59.187,21.813 a 8.716,8.716 0 0 1 -2.52,-6.222 8,8 0 0 1 2.52,-5.8 8.915,8.915 0 0 1 6.223,-2.458 8.288,8.288 0 0 1 5.8,2.459 8.18,8.18 0 0 1 2.457,5.799 8.918,8.918 0 0 1 -2.46,6.223 8,8 0 0 1 -5.797,2.519 8.714,8.714 0 0 1 -6.223,-2.52 z m -0.52,-5.739 a 6.521,6.521 0 0 0 6.741,6.259 6.423,6.423 0 0 0 6.259,-6.259 6.521,6.521 0 0 0 -6.259,-6.741 6.593,6.593 0 0 0 -6.741,6.741 z m 3.5,-0.241 a 3,3 0 1 1 3,3 3,3 0 0 1 -3,-3 z" fill="#ffffff" />
</svg>
</div>
<div class="ddd-canvas-indicator" />
</template>
</div>
</template>
<style scoped lang="less">
.ddd-canvas {
--canvas-measure-line: v-bind(measureLine);
z-index: var(--emulator-z-index);
position: absolute;
top: 0;
width: 375px;
min-height: var(--canvas-min-height);
display: flex;
flex-direction: column;
align-items: stretch;
background-color: v-bind(backgroundColor);
opacity: var(--emulator-opacity, 1);
user-select: none;
box-shadow: 0 2px 4px 1px rgba(40, 120, 255, 0.08), 0 0 6px 1px rgba(0, 0, 0, 0.08);
&.is-focused,
&.is-active {
outline: var(--canvas-measure-line) solid var(--canvas-active-highlight-color);
}
&.is-hover {
outline: var(--canvas-measure-line) solid var(--canvas-hover-highlight-color);
}
&-viewer {
z-index: 1;
position: relative;
min-height: var(--canvas-min-height);
//outline: 1px dashed red;
}
&-header,
&-footer {
position: absolute;
left: 0;
right: 0;
overflow: hidden;
&.is-bordered::after {
z-index: 2;
position: absolute;
left: 0;
right: 0;
content: "";
}
}
&-header {
z-index: 2;
top: 0;
background-color: v-bind(headerColor);
color: v-bind(headerTextColor);
&.is-bordered::after {
bottom: 0;
border-bottom-style: solid;
border-bottom-width: 1px;
border-bottom-color: v-bind(headerBorderColor);
}
&-title {
line-height: v-bind(headerHeight);
height: v-bind(headerHeight);
text-align: left;
font-weight: bold;
padding-left: 12px;
font-size: 16px;
}
}
&-footer {
z-index: 3;
bottom: 0;
background-color: v-bind(footerColor);
color: v-bind(footerTextColor);
&-content {
height: v-bind(footerHeight);
}
&.is-bordered::after {
top: 0;
border-top-style: solid;
border-top-width: 1px;
border-top-color: v-bind(footerBorderColor);
}
}
&-statusbar,
&-indicator,
&-routine-button {
z-index: 3;
position: absolute;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
//outline: 1px dashed #0256FF;
}
&-statusbar {
height: var(--canvas-safe-area-inset-top);
padding: 0 12px;
top: 0;
img {
width: 100%;
}
}
&-routine-button {
top: var(--canvas-safe-area-inset-top);
height: 44px;
width: 100%;
align-items: flex-end;
padding-right: 12px;
}
&-indicator {
bottom: 0;
height: var(--canvas-safe-area-inset-bottom);
//outline: 1px dashed green;
&::after {
display: block;
content: "";
width: 100px;
height: 4px;
margin: 0 auto;
border-radius: 2px;
background: #242424;
}
}
&-safe-area-inset-top {
height: var(--canvas-safe-area-inset-top);
}
&-safe-area-inset-header {
height: v-bind(headerHeight);
}
&-safe-area-inset-bottom {
height: var(--canvas-safe-area-inset-bottom);
}
&-safe-area-inset-footer {
height: v-bind(footerHeight);
}
}
</style>

@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { ConfiguratorType } from '../context'
import { useContext, useModule } from '../context'
defineOptions({
name: 'DddConfigurator',
})
interface Tab {
key: ConfiguratorType
text: string
}
const tabs: Tab[] = [
{ key: 'page', text: '页面' },
{ key: 'block', text: '内容' },
{ key: 'view', text: '样式' },
]
const currentTab = ref<Tab['key']>()
const ctx = useContext()
const module = useModule()
watch(() => ctx.configurator, (v) => {
currentTab.value = tabs.find(i => i.key === (v ?? 'page'))?.key ?? 'page'
})
</script>
<template>
<div class="ddd-configurator">
<div class="ddd-configurator-header">
<button
v-for="(t) in tabs"
:key="t.key"
type="button"
:class="{ 'is-active': currentTab === t.key }"
@click="currentTab = t.key"
v-text="t.text"
/>
</div>
<div v-show="currentTab === 'page' " class="ddd-configurator-content">
<fieldset>
<legend>应用</legend>
<div>
<label>
<input v-model="ctx.target" type="radio" name="ctx.target" value="routine" />
<span>微信小程序</span>
</label>
<label>
<input v-model="ctx.target" type="radio" name="ctx.target" value="app" />
<span>手机APP</span>
</label>
</div>
<div>
<label>
<input v-model="ctx.page.fullscreen" type="checkbox" />
<span>全屏模式</span>
</label>
</div>
<div>
<label>
<input v-model="ctx.page.safeAreaInsetTop" type="checkbox" />
<span>开启顶部安全区域</span>
</label>
</div>
<div>
<label>
<input v-model="ctx.page.safeAreaInsetBottom" type="checkbox" />
<span>开启底部安全区域</span>
</label>
</div>
<div>
<label>
<span>页面背景颜色</span>
<input v-model="ctx.page.backgroundColor" type="color" />
</label>
</div>
</fieldset>
<fieldset>
<legend>头部</legend>
<div>
<label>
<input v-model="ctx.page.header.enabled" type="checkbox" />
<span>开启头部功能</span>
</label>
</div>
<div>
<label>
<span>页面标题</span>
<input v-model="ctx.page.header.title" type="text" placeholder="页面标题" />
</label>
<label>
<input v-model="ctx.page.header.centerTitle" type="checkbox" />
<span>标题居中</span>
</label>
</div>
<div>
<label>
<input v-model="ctx.page.header.custom" type="checkbox" />
<span>内容自定义</span>
</label>
</div>
<div>
<label>
<span>高度</span>
<input v-model="ctx.page.header.height" type="number" min="0" placeholder="头部高度,不包括状态栏" />
</label>
</div>
<div>
<label>
<span>背景颜色</span>
<input v-model="ctx.page.header.color" type="color" />
</label>
</div>
<div>
<label>
<span>文字颜色</span>
<input v-model="ctx.page.header.textColor" type="color" />
</label>
</div>
<div>
<label>
<input v-model="ctx.page.header.bordered" type="checkbox" />
<span>开启下边边框</span>
</label>
</div>
<div>
<label>
<span>下边边框颜色</span>
<input v-model="ctx.page.header.borderColor" type="color" />
</label>
</div>
</fieldset>
<fieldset>
<legend>底部</legend>
<div>
<label>
<input v-model="ctx.page.footer.enabled" type="checkbox" />
<span>开启底部功能</span>
</label>
</div>
<div>
<label>
<span>高度</span>
<input v-model="ctx.page.footer.height" type="number" min="0" placeholder="头部高度,不包括状态栏" />
</label>
</div>
<div>
<label>
<span>背景颜色</span>
<input v-model="ctx.page.footer.color" type="color" />
</label>
</div>
<div>
<label>
<span>文字颜色</span>
<input v-model="ctx.page.footer.textColor" type="color" />
</label>
</div>
<div>
<label>
<input v-model="ctx.page.footer.bordered" type="checkbox" />
<span>开启上边框</span>
</label>
</div>
<div>
<label>
<span>上边框颜色</span>
<input v-model="ctx.page.footer.borderColor" type="color" />
</label>
</div>
</fieldset>
</div>
<div v-show="currentTab === 'block'" class="ddd-configurator-content">
<div>
<pre><code>{{ module }}</code></pre>
</div>
</div>
<div v-show="currentTab === 'view'" class="ddd-configurator-content">
<div>
<pre><code>{{ ctx.blocks }}</code></pre>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.ddd-configurator {
width: 100%;
height: 100%;
// fixme: 使 fieldset
//
fieldset {
background: transparent;
border-color: #eee;
border-radius: 8px;
}
&-header {
z-index: 2;
position: sticky;
display: flex;
top: 0;
width: 100%;
height: 44px;
line-height: 44px;
padding: 0 12px;
flex-shrink: 0;
background: #fff;
border-bottom: 1px solid #ddd;
button {
position: relative;
height: 44px;
padding: 0 16px;
border: none;
background: transparent;
appearance: none;
box-sizing: border-box;
transition: all 200ms;
&::after {
position: absolute;
content: "";
bottom: 0;
left: 0;
right: 0;
height: 2px;
transition: all 200ms;
}
&.is-active {
background: rgba(#0256FF, 0.1);
color: #0256FF;
&::after {
background-color: #0256FF;
}
}
}
}
&-content {
position: relative;
width: 100%;
height: 100%;
flex: 1;
padding: 12px;
overflow: auto;
//background-color: #eee;
}
}
</style>

@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useContext, useScale } from '../context'
import { DddView } from '../views'
import DddCanvas from './Canvas.vue'
defineOptions({
name: 'DddDesigner',
})
const ctx = useContext()
const scale = useScale()
let pageX = 0
let pageY = 0
const canMove = ref(false)
const x = ref((window.innerWidth - 375 - 324) / 2 + 324)
const y = ref(24)
function onMousedown(e: MouseEvent): void {
canMove.value = true
pageX = e.pageX - x.value
pageY = e.pageY - y.value
ctx.activeBlockId = undefined
ctx.activeViewId = undefined
ctx.configurator = undefined
ctx.canvas.focused = true
}
function onMousemove(e: MouseEvent): void {
if (canMove.value) {
x.value = e.pageX - pageX!
y.value = e.pageY - pageY!
}
}
function onMouseup(): void {
canMove.value = false
}
function onMousewheel(e: WheelEvent): void {
if (e.ctrlKey || e.metaKey) {
if (e.deltaY < 0)
scale.incr()
else
scale.decr()
}
else {
x.value -= e.deltaX
y.value -= e.deltaY
}
}
// TODO(hupeh): 使 Vue
function onMouseover(e: MouseEvent): void {
if (!ctx.draggingViewId) {
let el = e.target as HTMLElement | null | undefined
while (el) {
const widgetId = (el as HTMLElement)?.dataset?.viewId
if (widgetId && widgetId !== ctx.activeViewId) {
ctx.hoverViewId = widgetId
return
}
el = el?.parentNode as HTMLElement
}
ctx.hoverViewId = '#canvas'
}
}
</script>
<template>
<div
class="ddd-designer"
@mousedown.left="onMousedown"
@mousemove="onMousemove"
@mouseup="onMouseup"
@mouseleave="onMouseup"
@wheel.passive="onMousewheel"
>
<DddView
:is="DddCanvas"
:style="`transform: translate(${x}px,${y}px) scale(${ctx.canvas.scale})`"
vid="#canvas"
@mouseover="onMouseover"
@mousedown.stop
@mousemove.stop
/>
</div>
</template>
<style lang="less" scoped>
.ddd-designer {
z-index: 1;
position: relative;
width: 100%;
height: 100%;
background-color: #eee;
}
</style>

@ -0,0 +1,126 @@
<script setup lang="ts">
import { useContext, useScale } from '../context'
import Activity from './Activity.vue'
import Configurator from './Configurator.vue'
import Designer from './Designer.vue'
defineOptions({
name: 'DddEngineer',
})
const ctx = useContext()
const scale = useScale()
</script>
<template>
<div class="ddd-engineer">
<div class="ddd-engineer-header">
<div>
<div />
</div>
<div style="display: flex;align-items: center">
<button
type="button"
:disabled="ctx.canvas.scale <= scale.min"
@click="scale.decr()"
v-text="'-'"
/>
<span
style="display: inline-block;width: 5em;text-align: center"
>{{ Math.ceil(ctx.canvas.scale * 100) }}%</span>
<button
type="button"
:disabled="ctx.canvas.scale >= scale.max"
@click="scale.incr()"
v-text="'+'"
/>
</div>
<div>
<button type="button" @click="ctx.configurator = 'page'">
页面配置
</button>
</div>
</div>
<div class="ddd-engineer-activity">
<Activity />
</div>
<transition name="ddd-engineer-configurator">
<div v-show="ctx.configurator" class="ddd-engineer-configurator">
<Configurator />
</div>
</transition>
<Designer class="ddd-engineer-body" />
</div>
</template>
<style scoped lang="less">
.ddd-engineer {
width: 100%;
height: 100%;
&-header {
z-index: 2;
position: relative;
width: 100%;
height: 44px;
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
padding: 0 12px;
box-sizing: border-box;
&::after {
z-index: 10;
position: absolute;
left: 0;
right: 0;
bottom: 0;
content: "";
border-bottom: 1px solid #ddd;
}
}
&-body {
z-index: 1;
position: relative;
width: 100%;
height: calc(100% - 44px);
background: #eee;
}
&-activity {
z-index: 3;
position: absolute;
left: 0;
bottom: 0;
top: 44px;
width: 324px;
background: #fff;
}
&-configurator {
z-index: 3;
position: absolute;
right: 0;
bottom: 0;
top: 44px;
width: 324px;
background-color: #fff;
opacity: 1;
overflow: hidden;
&-enter-from,
&-leave-to {
opacity: 0;
transform: translateX(100%);
box-shadow: none;
}
&-enter-active,
&-leave-active {
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
}
}
</style>

@ -0,0 +1,138 @@
import type { ComputedRef, InjectionKey, UnwrapNestedRefs } from 'vue'
import { computed, inject, provide } from 'vue'
import type { Block, Category, Module, Page } from './types'
import { valueOf } from './utils'
/** 画布配置 */
export interface CanvasConfig {
/** 画布是否聚焦 */
focused: boolean
/** 画布缩放比例 */
scale: number
}
export interface PageConfig extends Omit<Page, 'body'> {}
export type ConfiguratorType =
| 'page'
| 'block'
| 'view'
export interface Exported {
attrs: PageConfig
data: Record<string, Record<string, unknown>>
}
export interface EngineContext {
/** 目标 */
target?: 'app' | 'routine'
/** 当前选中的模板 */
activeBlockId?: string
/** 当前选中的可编辑组件 */
activeViewId?: string
/** 鼠标进入的可编辑组件 */
hoverViewId?: string
/** 正在被拖拽的模板 */
draggingViewId?: string
/** 当前配置 */
configurator?: ConfiguratorType
/** 页面配置 */
page: PageConfig
/** 画布配置 */
canvas: CanvasConfig
/** 可以分类 */
categories: Category[]
/** 布局视图列表 */
blocks: Block[]
/** 内部唯一标识 */
nextId: number
/** 数据源 */
sources: Record<string, Record<string, unknown>>
/** 到处数据 */
export: () => Exported
}
export const contextKey: InjectionKey<UnwrapNestedRefs<EngineContext>> = Symbol.for('ddd:engine')
export const parentViewIdKey: InjectionKey<string> = Symbol.for('ddd:view:parent:id')
const blockIdKey: InjectionKey<string> = Symbol.for('ddd:block:id')
export function useContext(): UnwrapNestedRefs<EngineContext> {
const config = inject(contextKey)
if (config == null)
throw new Error('no config found')
return config
}
export function useParentViewId(): string | undefined {
return inject(parentViewIdKey)
}
export function provideParentViewId(id: string | undefined): void {
if (id)
provide(parentViewIdKey, id)
}
export function useBlockId(): string | undefined {
return inject(blockIdKey)
}
export function provideBlockId(id: string): void {
provide(blockIdKey, id)
}
export function useModule() {
const ctx = useContext()
return computed((): Module | undefined => {
for (const block of ctx.blocks) {
if (block.vid !== ctx.activeBlockId)
continue
for (const category of ctx.categories) {
for (const module of category.modules) {
if (module.mid === block.mid)
return module
}
}
break
}
return undefined
})
}
export function useScale() {
const ctx = useContext()
const minScaleValue = 0.6
const maxScaleValue = 3.0
const scaleStep = 0.2
const setScale = (delta: number): void => {
ctx.canvas.scale = Math.max(
Math.min(
ctx.canvas.scale + scaleStep * delta,
maxScaleValue,
),
minScaleValue,
)
}
return {
min: minScaleValue,
max: maxScaleValue,
step: scaleStep,
incr: () => setScale(1),
decr: () => setScale(-1),
}
}
export function useSource<T = unknown>(source: string | undefined, fallback?: T): ComputedRef<T | undefined> {
const ctx = useContext()
const blockId = useBlockId()
return computed<T | undefined>(() => {
if (!blockId)
throw new Error('without block')
if (!source)
return fallback
return valueOf(ctx.sources[blockId], source) ?? fallback
})
}

@ -0,0 +1,23 @@
import DddBuilder from './components/Builder.vue'
export type { WidgetRenderFunction } from './render'
export type {
CanvasConfig,
PageConfig,
ConfiguratorType,
EngineContext,
Exported,
} from './context'
export { registerWidget, render } from './render'
export {
useContext,
useBlockId,
useSource,
useModule,
useParentViewId,
} from './context'
export { hash, valueOf } from './utils'
export * from './types'
export default DddBuilder

@ -0,0 +1,175 @@
import type {
CSSProperties,
Component,
Prop,
VNodeChild,
} from 'vue'
import { defineComponent, h } from 'vue'
import type {
Axis,
Block,
DataRefer,
View,
ViewChildren,
Widget,
} from './types'
import { provideBlockId } from './context'
import {
align,
background,
bordering,
gap,
insets,
radii,
shadowing,
size,
unit,
} from './utils'
import DddView from './views/View.vue'
import DddTextView from './views/TextView.vue'
import DddImageView from './views/ImageView.vue'
import DddAudioView from './views/AudioView.vue'
/**
*
*/
export type WidgetRenderFunction = (widget: Widget, view: View, axis?: Axis) => VNodeChild
/**
*
*/
const widgets: Record<string, WidgetRenderFunction> = {}
export function registerWidget(some: Record<string, WidgetRenderFunction>): void {
for (const [key, func] of Object.entries(some))
widgets[key] = func
}
const isWidget = (s: any): s is Widget => s != null && 'name' in s
function isRefer(s: any): s is DataRefer {
return s != null
&& 'type' in s
&& 'key' in s
&& typeof s.type === 'string'
&& typeof s.key === 'string'
}
export function render(view?: ViewChildren, axis?: Axis): VNodeChild {
if (view == null)
return null
if (typeof view === 'string' || typeof view === 'number')
return view
if (Array.isArray(view))
return view.map(v => render(v, axis))
if (isRefer(view)) {
const { key, ...attrs } = view
let component: Component | undefined
switch (view.type) {
case 'text':
component = DddTextView
return h(DddTextView, {
source: view.key,
})
case 'image':
component = DddImageView
return h(DddImageView, {
source: key,
...attrs,
})
case 'audio':
component = DddAudioView
return h(DddAudioView, {
source: view.key,
title: view.title,
})
case 'video':
// todo ...
}
if (component) {
return h(component, {
source: key,
...attrs,
})
}
return h('b', {
style: {
color: 'red',
fontSize: '36px',
},
}, 'unimplemented')
}
if (isWidget(view.theme)) {
const func = widgets[view.theme.name]
if (typeof func !== 'function')
throw new Error(`unknown widget: ${view.theme.name}`)
const widget = view.theme
return func(widget, view, axis)
}
const theme = view.theme
const css: CSSProperties = {
// boxed & spatial
...insets('padding', theme?.padding),
...insets('margin', theme?.margin),
...size(axis, theme),
// decoration
...background(theme?.color, theme?.image, theme?.gradient),
...bordering(theme?.border),
...radii(theme?.radius),
...shadowing(theme?.shadow),
// textual
color: theme?.textColor,
fontSize: unit(theme?.fontSize),
textAlign: theme?.textAlign,
lineHeight: theme?.lineHeight,
// clip
overflow: theme?.clip,
}
// note: 在前面初始化 css 时使用了参数 axis,
// 所以在这里复用它用于子视图构建
axis = theme?.axis
if (theme?.flexible) {
Object.assign(css, {
display: 'flex',
flexDirection: axis === 'y' ? 'column' : undefined,
justifyContent: align(theme?.mainAlign),
alignItems: align(theme?.crossAlign),
flexWrap: theme?.wrap ? 'wrap' : undefined,
...gap(theme?.gap),
})
}
return h(DddView, {
vid: view.vid,
class: 'ddd-view',
style: css,
}, () => render(view.children, axis))
}
export const RenderBlock = defineComponent({
name: 'RenderWidget',
props: {
block: {
type: Object,
required: true,
} as Prop<Block>,
},
setup(props) {
provideBlockId(props.block!.vid)
return () => {
if (props.block == null)
return null
return render(props.block)
}
},
})

@ -0,0 +1,558 @@
import type { Slots } from 'vue'
/** 百分比 */
export type Percentage = `${number}%`
/** 计量原语 */
export type Unit =
| number // 根据情况,大多数情况下被作为逻辑像素使用
| Percentage
/** 对称策略 */
export interface Symmetric<T> {
vertical?: T
horizontal?: T
}
/** 四边策略 */
export interface Direction<T> {
left?: T
right?: T
top?: T
bottom?: T
}
/** 四角策略 */
export interface Corners<T> {
topLeft?: T // 左上角
topRight?: T // 右上角
bottomLeft?: T // 左下角
bottomRight?: T // 右下角
}
/**
* 线
*
* sweep radial
*
* stops colors
*/
export interface Gradient {
/** 渐变角度 */
angle?: string
/** 颜色组,至少 2 个颜色值 */
colors: string[]
/** 颜色停顿位置组 */
stops?: number[]
/** 是否重复渲染 */
repeatable?: boolean
}
/** 边距,用于内外边距 */
export type EdgeInsets =
| Direction<Unit> // 四边
| Symmetric<Unit> // 对称
| Unit // 原语
/** 边框样式 */
export type BorderStyle =
| 'solid'
| 'dashed'
| 'dotted'
| 'double'
| 'groove'
| 'ridge'
| 'outset'
| 'inset'
| 'none'
/** 边框的边 */
export interface BorderSide {
color?: string
style?: BorderStyle
width?: number
}
/** 边框 */
export type Border =
| Direction<BorderSide> // 四边
| Symmetric<BorderSide> // 对称
| BorderSide // 统一设置
/** 圆角原语 */
export type RadiusPrimitive =
| number
| [number, number]
/** 圆角双值 */
export type RadiusDouble = [
RadiusPrimitive,
RadiusPrimitive,
]
/** 圆角 */
export type Radius =
| Corners<RadiusPrimitive | RadiusDouble>
| RadiusPrimitive
| RadiusDouble
/** 阴影 */
export interface Shadow {
x: number
y: number
blur?: number
spread?: number
color: string
inset?: boolean
}
/**
* ()
*
* Flex CSS justify-content
*/
export type MainAlign =
| 'start' // 左对齐,默认值
| 'end' // 右对齐
| 'center' // 居中
| 'between' // 两端对齐,项目之间的间隔都相等。
| 'around' // 每个项目两侧的间隔相等。所以,项目之间的间隔比项目与边框的间隔大一倍。
/**
*
*
* Flex CSS align-items
*/
export type CrossAlign =
| 'start' // 交叉轴的起点对齐。
| 'end' // 交叉轴的终点对齐。
| 'center' // 交叉轴的中点对齐。
| 'baseline' // 项目的第一行文字的基线对齐。
| 'stretch' // 默认值):如果项目未设置高度或设为auto,将占满整个容器的高度。
/** 文本方向 */
export type TextAlign =
| 'left'
| 'center'
| 'right'
/**
*
*
* x -
* y -
*/
export type Axis = 'x' | 'y'
/**
*
*
*
*/
export interface Textual {
/** 文本颜色 */
textColor?: string
/** 字体大小 */
fontSize?: number
/** 文本方向 */
textAlign?: TextAlign
/** 行高,行高比较特殊,需要转换单位 */
lineHeight?: number | Percentage | string
}
/**
*
*
*
*/
export interface Flexible {
/**
* CSS flex-direction
* 'x' `row` 'y' `column`
*/
axis?: Axis
/** 主轴对齐方式 */
mainAlign?: MainAlign
/** 交叉轴对齐方式 */
crossAlign?: CrossAlign
/** 是否自动换行 */
wrap?: boolean
/**
*
*
*
*
* -
* -
*/
gap?: number | [number, number]
}
/**
*
*
*
*/
export interface Decoration {
/** 组件颜色,背景颜色 */
color?: string
/** 组件图片,背景图片 */
image?: string
/** 组件边框 */
border?: Border
/** 组件圆角 */
radius?: Radius
/** 组件阴影效果 */
shadow?: Shadow | Shadow[]
/** 组件渐变,背景渐变 */
gradient?: Gradient
}
/**
*
*
*
*/
export interface Spatial {
/** 当组件作为 Flex 的子组件时,视图在主轴方向上的初始大小 */
basis?: number
/** 在 Flex 布局下的增长系数,对应 flex-grow */
grow?: number
/** 在 Flex 布局下的收缩规则 */
shrink?: number
}
/**
*
*
*
*/
export interface Boxed {
/** 组件内边距 */
padding?: EdgeInsets
/** 组件外边距 */
margin?: EdgeInsets
/** 组件宽度,若在 Flex 布局下可能会被作为主轴方向上的初始大小 */
width?: number | Percentage | 'auto'
/** 组件高度,若在 Flex 布局下可能会被作为主轴方向上的初始大小 */
height?: number | Percentage | 'auto'
}
/**
*
*
* 使
*/
export interface Widget {
/** 组件名称 */
name: string
/** 组件属性 */
props?: Record<string, unknown>
/** 相关插槽,不包括默认插槽 */
slots?: Omit<Slots, 'default'>
}
export type ClipBehavior =
| 'hidden'
| 'visible'
| 'scroll'
| 'auto'
/**
*
*
*
*/
export interface Theme extends Textual, Flexible, Boxed, Decoration, Spatial, Flexible {
/** 是否开启弹性布局,todo 删除改属性,根据属性判断是否启用 flex 布局 */
flexible?: boolean
/** 超出视图剪切方式,对应 overflow 属性 */
clip?: ClipBehavior
}
/** 文本配置引用 */
export interface TextRefer {
/** 标识为文本专用类型 */
type: 'text'
/** 对应的数据键名称 */
key: string
}
// https://developer.mozilla.org/zh-CN/docs/Web/CSS/object-fit
export type ObjectFit =
| 'fill'
| 'contain'
| 'cover'
| 'none'
| 'scale-down'
/** 图片配置引用 */
export interface ImageRefer {
/** 图片引用类型专用类型 */
type: 'image'
/** 图片地址键 */
key: string
/** 图片标题键 */
title?: string
/** 图片跳转链接键 */
link?: string
/** 图片填充方式 */
fit?: ObjectFit
/** 图片圆角 */
radius?: Radius
}
/** 视频配置引用 */
export interface VideoRefer {
/** 视频引用类型专用类型 */
type: 'video'
/** 视频地址键 */
key: string
/** 视频封片图片键 */
poster?: string
/** 视频标题键 */
title?: string
/** 视频填充方式 */
fit?: ObjectFit
// todo 其它配置实现,比如自动播放、控件配置等
}
/** 音频配置引用 */
export interface AudioRefer {
/** 音频引用类型专用 */
type: 'audio'
/** 音频地址键 */
key: string
/** 音频标题键,todo 是否需要显示?如何显示? */
title: string
// todo 其它配置实现,比如自动播放、控件配置等
}
/** 目前支持的 4 中配置引用 */
export type DataRefer =
| TextRefer
| ImageRefer
| VideoRefer
| AudioRefer
export type ViewChild =
| string
| number
| DataRefer
| View
export type ViewChildren = ViewChild | Array<ViewChild>
/**
*
*
*
*/
export interface View {
/** 视图唯一标识 */
vid?: string
/**
*
*
*
*/
theme?: Theme | Widget
/**
*
*/
source?: string
/** 子视图 */
children?: ViewChildren
}
/** 装修视图块 */
export interface Block extends View {
/** 视图唯一标识 */
vid: string
/** 模块唯一标识 */
mid: string
}
/** 装修模块 */
export interface Module extends Block {
/** 标题 */
title: string
/** 组件预览图 */
image?: string
/** 允许最大引用次数,如果值为负数则表示没有限制 */
maxReferenceCount?: number
/** 被引用次数,运行时属性 */
referenceCount?: number
/** 模块配置 */
configs: ModuleConfig[]
}
export type ModuleConfig =
| ListConfig
| ObjectConfig
| BackgroundConfig
| BooleanConfig
| MarkConfig
| ImageConfig
| BaseConfig
export interface BaseConfig {
type: string
field: string
label: string
help?: string
required?: boolean
}
export interface ListConfig extends BaseConfig {
type: 'list'
addable: boolean
min?: number
max?: number
configs: ModuleConfig[]
}
export interface ObjectConfig extends BaseConfig {
type: 'object'
configs: ModuleConfig[]
}
export interface BackgroundConfig extends BaseConfig {
type: 'background'
features: Array<'color' | 'gradient' | 'image'>
}
export interface ConfigValue<T = unknown> {
label: string
value: T
/** 帮助信息 */
help?: string
/** 以图标形式渲染 */
icon?: string
}
/** 针对单选或多选 */
export interface MarkConfig extends BaseConfig {
type: 'mark'
multiple?: boolean
values: ConfigValue[]
}
export interface BooleanConfig extends BaseConfig {
type: 'bool'
}
export interface ImageConfig extends BaseConfig {
type: 'image'
/** 内联,表示相关联的数据 */
inlines?: ModuleConfig[]
}
/** 模块分类 */
export interface Category {
/** 分类图标 */
icon: string
/** 分类名称 */
text: string
/** 模块列表 */
modules: Module[]
}
/**
*
*
* todo contrast
*/
export type Brightness =
| 'dark'
| 'light'
/** 页面头尾固定区域配置 */
export interface StickyConfig {
/** 是否开启 */
enabled: boolean
/** 高度 */
height: number
/** 背景颜色 */
color: string
/** 文本颜色 */
textColor: string
/** 是否开启边框 */
bordered: boolean
/** 边框颜色 */
borderColor: string
}
/** 页面头部配置 */
export interface PageHeader extends StickyConfig {
/** 页面标题 */
title?: string
/** 页面标题是否居中显示,在小程序上非自定义头部时无效 */
centerTitle?: boolean
/** 自定义视图 */
custom?: View
}
/** 页面底部配置 */
export interface PageFooter extends StickyConfig {
/** 自定义视图,todo 小程序底部怎么弄?使用额外配置? */
view?: View
}
/**
*
*
* API接口
*/
export interface Page {
/** 亮度模式,支持暗模式和亮模式 */
brightness?: Brightness
/** 是否全屏模式 */
fullscreen?: boolean
/** 是否开启顶部安全区域(屏幕状态栏) */
safeAreaInsetTop?: boolean
/** 是否开启底部安全区域(底部操作条) */
safeAreaInsetBottom?: boolean
/** 页面背景颜色 */
backgroundColor?: string
/** 页面头部配置 */
header: PageHeader
/** 页面主体部分 */
body: Block[]
/** 页面底部配置 */
footer: PageFooter
}
// /** 交互类型 */
// export type Interaction =
// | 'carousel' // 轮播图
// | 'collapse' // 折叠面板
// | 'dialog' // 弹出层
// | 'drawer' // 抽屉
// | 'marquee' // 跑马灯(比如滚动通知)
// | 'count' // 计数(比如秒杀倒计时、短信验证码倒计时、数字滚动等)
// | 'keyboard' // 打开虚拟键盘
// | 'picker' // 打开 picker 面板
// | 'gesture' // 手势(长按、点击、双击、滑动等)
// | 'sticky' // 吸顶
//
// export interface Interop {
// /** 交互方式 */
// type: Interaction
// /**
// * 交互视图数据列表
// */
// effect?: ViewData[]
// /**
// * 交互关联视图列表
// *
// * 如果存在同类型的,属性会根据顺序被覆盖。
// */
// attach?: ViewData[]
// }
//
// /** 描述视图动画 */
// export interface Motion {
// /** 动画类型 */
// type: string
// }

@ -0,0 +1,14 @@
import type { CrossAlign, MainAlign } from '../types'
export function align(s: MainAlign | CrossAlign | undefined): string | undefined {
switch (s) {
case 'start':
case 'end':
return `flex-${s}`
case 'between':
case 'around':
return `space-${s}`
default:
return s
}
}

@ -0,0 +1,27 @@
import type { CSSProperties } from 'vue'
import type { Gradient } from '../types'
function image(img: string | undefined, g: Gradient | undefined): string | undefined {
if (img == null && g == null)
return undefined
if (img != null)
return `url(${img})`
if (g == null)
return undefined
const prefix = g.repeatable ? 'repeating-' : ''
if (g.stops?.length === g.colors.length) {
const colors = g.colors.map((c, i) => `${c} ${g.stops![i]}`).join(', ')
return `${prefix}linear-gradient(${g?.angle ?? '0deg'}, ${colors})`
}
return `${prefix}linear-gradient(${g?.angle ?? '0deg'}, ${g.colors.join(', ')})`
}
export function background(color: string | undefined, img: string | undefined, g: Gradient | undefined): CSSProperties {
return {
backgroundColor: color,
backgroundImage: image(img, g),
}
}

@ -0,0 +1,49 @@
import type { CSSProperties } from 'vue'
import type { Border, BorderSide } from '../types'
import { unit } from './unit'
import { isBorderSide, isSymmetric } from './is'
function borderSide(name: string, s: BorderSide | undefined): CSSProperties | undefined {
if (s == null)
return undefined
const hasWidth = s.width != null
const hasColor = s.color != null
const hasStyle = s.style != null
const r: Record<string, string | undefined> = {}
if (hasColor)
r[`${name}Color`] = s.color
if (hasWidth)
r[`${name}Width`] = unit(s.width)
else if (hasColor)
r[`${name}Width`] = '1px'
if (hasStyle)
r[`${name}Style`] = s.style
else if (hasWidth || hasColor)
r[`${name}Style`] = 'solid'
return r
}
export function bordering(b: Border | undefined): CSSProperties | undefined {
if (b == null)
return undefined
if (isBorderSide(b))
return borderSide('border', b)
if (isSymmetric(b)) {
return {
...borderSide('borderBlock', b.vertical),
...borderSide('borderInline', b.horizontal),
}
}
return {
...borderSide('borderTop', b.top),
...borderSide('borderBottom', b.bottom),
...borderSide('borderLeft', b.left),
...borderSide('borderRight', b.right),
}
}

@ -0,0 +1,19 @@
import type { CSSProperties } from 'vue'
import { unit } from './unit'
export function gap(gap: number | [number, number] | undefined): CSSProperties | undefined {
if (!gap) {
return undefined
}
else if (Array.isArray(gap)) {
return {
rowGap: unit(gap[0]),
columnGap: unit(gap[1]),
}
}
else {
return {
gap: unit(gap),
}
}
}

@ -0,0 +1,17 @@
/**
* Calculate a 32 bit FNV-1a hash
* Found here: https://gist.github.com/vaiorabbit/5657561
* Ref.: http://isthe.com/chongo/tech/comp/fnv/
*
* @param {string} str the input value
* @returns {string} return the 8-digit hex string
*/
export function hash(str: string): string {
let hval = 0x811C9DC5
for (let i = 0, l = str.length; i < l; i++) {
hval ^= str.charCodeAt(i)
hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24)
}
// Convert to 8 digit hex string
return (`0000000${(hval >>> 0).toString(16)}`).slice(-8)
}

@ -0,0 +1,12 @@
export * from './align'
export * from './background'
export * from './border'
export * from './gap'
export * from './hash'
export * from './insets'
export * from './is'
export * from './object'
export * from './radii'
export * from './shadow'
export * from './size'
export * from './unit'

@ -0,0 +1,27 @@
import type { CSSProperties } from 'vue'
import type { EdgeInsets } from '../types'
import { isSymmetric } from './is'
import { unit } from './unit'
export function insets(name: string, i: EdgeInsets | undefined): CSSProperties | undefined {
if (i == null)
return undefined
if (typeof i === 'number')
return { [name]: `${i}px` }
if (typeof i === 'string')
return { [name]: i }
if (isSymmetric(i)) {
return {
[name]: [unit(i.vertical), unit(i.horizontal)].join(' '),
}
}
return {
[`${name}Top`]: unit(i.top),
[`${name}Bottom`]: unit(i.bottom),
[`${name}Left`]: unit(i.left),
[`${name}Right`]: unit(i.right),
}
}

@ -0,0 +1,25 @@
import type { BorderSide, Corners, Symmetric } from '../types'
export function isSymmetric(a: any): a is Symmetric<any> {
return a != null
&& !Array.isArray(a)
&& typeof a === 'object'
&& ('vertical' in a || 'horizontal' in a)
}
export function isBorderSide(a: any): a is BorderSide {
return a != null
&& !Array.isArray(a)
&& typeof a === 'object'
&& ('color' in a || 'style' in a || 'width' in a)
}
export function isCorners(a: any): a is Corners<any> {
return a != null
&& !Array.isArray(a)
&& typeof a === 'object'
&& ('topLeft' in a
|| 'topRight' in a
|| 'bottomLeft' in a
|| 'bottomRight' in a)
}

@ -0,0 +1,23 @@
function find(obj: any, keys: string[]) {
if (!keys.length)
return obj
if (obj == null || typeof obj !== 'object')
return undefined
const key = keys.shift()!
if (!Array.isArray(obj))
return find(obj[key], keys)
const index = Number.parseInt(key)
if (Number.isNaN(index))
return undefined
return find(obj[index], keys)
}
export function valueOf(obj: any, key: string) {
if (!key)
return undefined
return find(obj, key.split('.'))
}

@ -0,0 +1,30 @@
import type { CSSProperties } from 'vue'
import type { Radius, RadiusDouble, RadiusPrimitive } from '../types'
import { isCorners } from './is'
function stringify(radius: RadiusPrimitive | RadiusDouble | undefined): string | undefined {
if (radius == null)
return undefined
if (Array.isArray(radius))
return radius.map(stringify).join(' ')
return `${radius}px`
}
export function radii(radius: Radius | undefined): CSSProperties | undefined {
if (radius == null)
return undefined
if (isCorners(radius)) {
return {
borderTopLeftRadius: stringify(radius.topLeft),
borderTopRightRadius: stringify(radius.topRight),
borderBottomLeftRadius: stringify(radius.bottomLeft),
borderBottomRightRadius: stringify(radius.bottomRight),
}
}
return {
borderRadius: stringify(radius),
}
}

@ -0,0 +1,37 @@
import type { CSSProperties } from 'vue'
import type { Shadow } from '../types'
const unit = (n: number | undefined): string | undefined => n == null ? undefined : n === 0 ? n.toString() : `${n}px`
function stringify(s: Shadow | undefined): string | undefined {
if (s == null)
return undefined
return [
s.inset ? 'inset' : undefined,
unit(s.x),
unit(s.y),
s.blur != null ? unit(s.blur) : s.spread != null ? '0' : undefined,
unit(s.spread),
s.color,
]
.filter(n => n != null)
.join(' ')
}
export function shadowing(ss: Shadow | Shadow[] | undefined): CSSProperties | undefined {
if (ss == null)
return undefined
if (!Array.isArray(ss)) {
return {
boxShadow: stringify(ss),
}
}
return {
boxShadow: ss
.map(stringify)
.filter(Boolean)
.join(', '),
}
}

@ -0,0 +1,18 @@
import type { CSSProperties } from 'vue'
import type { Axis, Theme } from '../types'
import { unit } from './unit'
export function size(
axis: Axis | undefined,
theme: Theme | undefined,
): CSSProperties {
const width = unit(theme?.width)
const height = unit(theme?.height)
return {
width,
height,
flexBasis: theme?.basis ?? (axis === 'x' ? width : axis === 'y' ? height : undefined),
flexGrow: theme?.grow != null ? `${theme.grow}` : undefined,
flexShrink: theme?.shrink != null ? `${theme.shrink}` : undefined,
}
}

@ -0,0 +1,7 @@
export function unit(n: number | undefined | string) {
if (n == null)
return undefined
if (typeof n !== 'number')
return n
return `${n}px`
}

@ -0,0 +1,22 @@
<script setup lang="ts">
import { useSource } from '../context'
defineOptions({
name: 'DddAudioView',
})
const props = defineProps<{
source: string
title?: string
}>()
const src = useSource<string>(props.source)
const title = useSource<string>(props.title)
</script>
<template>
<div style="display:inline-block">
<div>{{ title }}</div>
<audio v-if="src" :src="src" controls />
</div>
</template>

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ObjectFit, Radius } from '../types'
import { useSource } from '../context'
import { radii } from '../utils'
defineOptions({
name: 'DddImageView',
})
const props = defineProps<{
source: string
title?: string
link?: string
fit?: ObjectFit
radius?: Radius
}>()
// todo
const src = useSource<string>(props.source, '')
const title = useSource<string>(props.title)
// const link = useSource<string>(props.link)
</script>
<template>
<img
:src="src"
:alt="title"
:style="{
objectFit: fit ?? 'contain',
maxWidth: '100%',
...radii(radius),
}"
/>
</template>
<style scoped lang="less">
</style>

@ -0,0 +1,17 @@
<script setup lang="ts">
import { useSource } from '../context'
defineOptions({
name: 'DddTextView',
})
const props = defineProps<{
source: string
}>()
const text = useSource<string>(props.source)
</script>
<template>
{{ text }}
</template>

@ -0,0 +1,116 @@
<script setup lang="ts">
import type { Component } from 'vue'
import {
provideParentViewId,
useBlockId,
useContext,
useParentViewId,
} from '../context'
defineOptions({
name: 'DddView',
})
const props = defineProps<{
is?: string | Component
vid?: string
}>()
const ctx = useContext()
const parentId = useParentViewId()
const blockId = useBlockId()
function handleMousedown(e: Event, id: string | undefined): void {
ctx.activeBlockId = blockId
ctx.hoverViewId = parentId
ctx.canvas.focused = false
if (blockId)
ctx.configurator = 'block'
if (id) {
e.stopPropagation()
ctx!.activeViewId = id
if (blockId !== id) {
switch (id) {
case '#canvas':
ctx.configurator = 'page'
break
case '#header':
case '#footer':
ctx.configurator = 'block'
break
default:
ctx.configurator = 'view'
}
}
}
}
function handleMouseleave(id: string | undefined): void {
if (id && ctx.hoverViewId === id && !ctx.draggingViewId)
ctx.hoverViewId = undefined
}
provideParentViewId(props.vid)
</script>
<template>
<component
:is="is ?? 'div'"
class="ddd-view"
:class="{
'is-active': vid && ctx.activeViewId === vid,
'is-hover': vid && ctx.hoverViewId === vid,
}"
:data-view-id="vid"
@mouseleave="handleMouseleave(vid)"
@mousedown.left="handleMousedown($event, vid)"
>
<slot />
<!-- 使用全局单例利用 absolute 固定位置 -->
<div class="ddd-view-highlight" />
</component>
</template>
<style lang="less" scoped>
.ddd-view {
z-index: 1;
position: relative;
> .ddd-view-highlight {
z-index: 10;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
content: "";
border: var(--canvas-measure-line) solid transparent;
pointer-events: none;
}
&.is-hover {
z-index: 3;
> .ddd-view-highlight {
border-color: var(--canvas-hover-highlight-color);
}
}
&.is-active {
z-index: 2;
> .ddd-view-highlight {
border-color: var(--canvas-active-highlight-color);
}
}
&.is-ghost {
opacity: 0.3;
> .ddd-view-highlight {
border-style: dashed;
}
}
}
</style>

@ -0,0 +1,4 @@
export { default as DddAudioView } from './AudioView.vue'
export { default as DddImageView } from './ImageView.vue'
export { default as DddTextView } from './TextView.vue'
export { default as DddView } from './View.vue'

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.less'
import App from './App.vue'
createApp(App).mount('#app')

@ -0,0 +1,40 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
//color: rgba(255, 255, 255, 0.87);
//background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--canvas-safe-area-inset-top: 36px;
--canvas-safe-area-inset-bottom: 18px;
--canvas-min-height: 736px; // 812px;
--canvas-hover-highlight-color: #0256FF;
--canvas-active-highlight-color: red;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
#app {
width: 100vw;
height: 100vh;
/*background: #eee;*/
}

6
src/vite-env.d.ts vendored

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
type Tuple<Len, Elm = unknown, Arr extends Elm[] = Elm[]> =
Arr['length'] extends Len
? Arr
: never

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"jsx": "preserve",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"references": [{ "path": "./tsconfig.node.json" }],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
})
Loading…
Cancel
Save