commit
ddf6148d14
@ -0,0 +1 @@ |
||||
* text=auto eol=lf |
@ -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> |
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" |
||||
} |
||||
} |
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> |
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;*/ |
||||
} |
@ -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…
Reference in new issue