all
This commit is contained in:
4
packages/core/.env
Normal file
4
packages/core/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# 默认环境变量配置(会被 .env.development 和 .env.production 覆盖)
|
||||
VITE_API_BASE_URL=https://cslab.oberyun.com
|
||||
|
||||
VITE_BASE_URL=/dcs-web/
|
||||
3
packages/core/.env.development
Normal file
3
packages/core/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://192.168.1.110:8001
|
||||
VITE_WS_DOMAIN=ws://192.168.1.110:6600
|
||||
2
packages/core/.env.production
Normal file
2
packages/core/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=https://cslab.oberyun.com
|
||||
22
packages/core/env.d.ts
vendored
Normal file
22
packages/core/env.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="element-plus/global" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<object, object, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
// 更多环境变量可以在这里添加...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
// 可以扩展 window 类型
|
||||
}
|
||||
50
packages/core/package.json
Normal file
50
packages/core/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@cslab-dcs/core",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./vite.shared": {
|
||||
"types": "./vite.shared.d.ts",
|
||||
"import": "./vite.shared.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cslab-dcs/bridge": "workspace:*",
|
||||
"@cslab-dcs/schema": "workspace:*",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"element-plus": "^2.9.0",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"mitt": "^3.0.1",
|
||||
"ofetch": "^1.5.1",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@unocss/preset-attributify": "^66.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||
"sass": "^1.83.0",
|
||||
"typescript": "^5.9.3",
|
||||
"unocss": "^66.6.0",
|
||||
"unocss-preset-chinese": "^0.3.3",
|
||||
"unocss-preset-ease": "^0.0.4",
|
||||
"unplugin-auto-import": "^19.0.0",
|
||||
"unplugin-vue-components": "^28.0.0",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
32
packages/core/src/App.vue
Normal file
32
packages/core/src/App.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { useBridge } from '@/bootstrap'
|
||||
|
||||
useBridge()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElConfigProvider :locale="zhCn">
|
||||
<router-view />
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
/* 全局样式 */
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* 确保 Element Plus 弹窗层级正确 */
|
||||
.el-overlay {
|
||||
position: fixed !important;
|
||||
z-index: 2000 !important;
|
||||
}
|
||||
</style>
|
||||
127
packages/core/src/api/canvas.ts
Normal file
127
packages/core/src/api/canvas.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Layer } from '@cslab-dcs/schema'
|
||||
import type { CanvasDetail, CanvasSummary } from '@/request'
|
||||
import { requestClient } from '@/request'
|
||||
import pinia, { useAppStore, useProjectStore } from '@/stores'
|
||||
|
||||
// 画布服务层:对 UI 提供稳定接口,屏蔽 request 底层适配器
|
||||
export interface CanvasCreatePayload {
|
||||
name: string
|
||||
description?: string
|
||||
thumbnail?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export interface CanvasUpdatePayload {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CanvasDuplicatePayload {
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface SaveCanvasComponentsPayload {
|
||||
id: string
|
||||
components: Layer[]
|
||||
}
|
||||
|
||||
export interface UpdateCanvasBaseLayerPayload {
|
||||
id: string
|
||||
width: number
|
||||
height: number
|
||||
thumbnail?: string | null
|
||||
lockAspectRatio?: boolean
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
export type CanvasListItem = CanvasSummary
|
||||
export type CanvasData = CanvasDetail
|
||||
|
||||
function getRequestClient() {
|
||||
const { isWeb } = useAppStore(pinia)
|
||||
if (!isWeb)
|
||||
return requestClient.local
|
||||
|
||||
return requestClient.local
|
||||
}
|
||||
|
||||
export async function listCanvasesApi(): Promise<CanvasListItem[]> {
|
||||
const { projectId } = useProjectStore(pinia)
|
||||
|
||||
if (!projectId)
|
||||
return []
|
||||
|
||||
return getRequestClient().canvas.list(projectId)
|
||||
}
|
||||
|
||||
export async function getCanvasByIdApi(canvasId: string): Promise<CanvasData | null> {
|
||||
return getRequestClient().canvas.getById(canvasId)
|
||||
}
|
||||
|
||||
export async function createCanvasApi(payload: CanvasCreatePayload): Promise<CanvasListItem> {
|
||||
const { projectId } = useProjectStore(pinia)
|
||||
if (!projectId)
|
||||
throw new Error('Project ID is required to create a canvas')
|
||||
|
||||
return getRequestClient().canvas.create({
|
||||
projectId,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
thumbnail: payload.thumbnail,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateCanvasApi(payload: CanvasUpdatePayload): Promise<CanvasListItem> {
|
||||
console.log('payload', payload)
|
||||
|
||||
return getRequestClient().canvas.update({
|
||||
canvasId: payload.id,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
})
|
||||
}
|
||||
|
||||
export async function duplicateCanvasApi(payload: CanvasDuplicatePayload): Promise<CanvasListItem> {
|
||||
return getRequestClient().canvas.duplicate({
|
||||
canvasId: payload.id,
|
||||
name: payload.name,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteCanvasApi(canvasId: string): Promise<void> {
|
||||
return getRequestClient().canvas.remove({ canvasId })
|
||||
}
|
||||
|
||||
// 批量更新画布排序
|
||||
export async function reorderCanvasesApi(canvasIds: string[]): Promise<void> {
|
||||
const { projectId } = useProjectStore(pinia)
|
||||
if (!projectId)
|
||||
throw new Error('Project ID is required to reorder canvases')
|
||||
|
||||
return getRequestClient().canvas.reorder({ projectId, canvasIds })
|
||||
}
|
||||
|
||||
// 编辑器自动保存组件快照
|
||||
export async function updateComponentsLayer(payload: SaveCanvasComponentsPayload): Promise<void> {
|
||||
return getRequestClient().canvas.updateComponentsLayer({
|
||||
canvasId: payload.id,
|
||||
components: payload.components,
|
||||
})
|
||||
}
|
||||
|
||||
// 更新画布背景图层(底图)属性
|
||||
export async function updateCanvasBaseLayer(payload: UpdateCanvasBaseLayerPayload): Promise<void> {
|
||||
return getRequestClient().canvas.updateBaseLayer({
|
||||
canvasId: payload.id,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
thumbnail: payload.thumbnail,
|
||||
lockAspectRatio: payload.lockAspectRatio,
|
||||
backgroundColor: payload.backgroundColor,
|
||||
})
|
||||
}
|
||||
112
packages/core/src/api/dynamic-project.ts
Normal file
112
packages/core/src/api/dynamic-project.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { createHttpClient } from '@/request/client'
|
||||
|
||||
export interface DynamicProjectModuleResponse {
|
||||
module_pk: string
|
||||
name: string
|
||||
describe: string
|
||||
label: string
|
||||
classify: string
|
||||
}
|
||||
|
||||
export interface DynamicProjectModule extends DynamicProjectModuleResponse {
|
||||
displayLabel: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface DynamicProjectModuleProp {
|
||||
prop_pk: string
|
||||
name: string
|
||||
describe: string
|
||||
t_module_prop: string
|
||||
classify: string
|
||||
}
|
||||
|
||||
export interface DynamicProjectProperty extends DynamicProjectModuleProp {
|
||||
displayLabel: string
|
||||
key: string
|
||||
defaultValue?: unknown
|
||||
}
|
||||
|
||||
const fetcher = createHttpClient()
|
||||
|
||||
export async function getDynamicProjectModulesApi(baseProjectId: string): Promise<DynamicProjectModule[]> {
|
||||
if (!baseProjectId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await fetcher<DynamicProjectModuleResponse[]>('/project/module/list/', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Hidden-Error': '1',
|
||||
'PROJECT': baseProjectId,
|
||||
},
|
||||
query: {
|
||||
pro: baseProjectId,
|
||||
isEnum: 1,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('[dynamic-project] modules 原始响应', data)
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.warn('[dynamic-project] modules 响应非数组', { type: typeof data, data })
|
||||
return []
|
||||
}
|
||||
|
||||
const result: DynamicProjectModule[] = data.map(module => ({
|
||||
...module,
|
||||
displayLabel: `${module.name} (${module.describe})`,
|
||||
key: module.module_pk,
|
||||
}))
|
||||
|
||||
console.log('[dynamic-project] modules 归一化结果', result)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getDynamicProjectModulePropsApi(baseProjectId: string, modulePk: string): Promise<DynamicProjectProperty[]> {
|
||||
if (!baseProjectId || !modulePk) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await fetcher<{ moduleProp: DynamicProjectModuleProp[] }>('/project/module/', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Hidden-Error': '1',
|
||||
'PROJECT': baseProjectId,
|
||||
},
|
||||
query: {
|
||||
pk: modulePk,
|
||||
pro: baseProjectId,
|
||||
isNeedFlow: 0,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[dynamic-project] module "${modulePk}" props 原始响应`, data)
|
||||
|
||||
const propList = data?.moduleProp
|
||||
if (!Array.isArray(propList)) {
|
||||
console.warn(`[dynamic-project] module "${modulePk}" props 响应非数组`, { type: typeof propList, data })
|
||||
return []
|
||||
}
|
||||
|
||||
// 按 name 去重:API 可能对同一属性返回多条记录,只保留首条
|
||||
const seen = new Set<string>()
|
||||
const result: DynamicProjectProperty[] = propList
|
||||
.filter(prop => prop)
|
||||
.filter((prop) => {
|
||||
if (seen.has(prop.name)) {
|
||||
return false
|
||||
}
|
||||
seen.add(prop.name)
|
||||
return true
|
||||
})
|
||||
.map(prop => ({
|
||||
...prop,
|
||||
displayLabel: `${prop.name} (${prop.describe})`,
|
||||
key: prop.prop_pk,
|
||||
defaultValue: undefined,
|
||||
}))
|
||||
|
||||
console.log(`[dynamic-project] module "${modulePk}" props 归一化结果`, { count: result.length })
|
||||
return result
|
||||
}
|
||||
4
packages/core/src/api/index.ts
Normal file
4
packages/core/src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './canvas'
|
||||
export * from './dynamic-project'
|
||||
export * from './project'
|
||||
export * from './runtime'
|
||||
15
packages/core/src/api/project.ts
Normal file
15
packages/core/src/api/project.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ProjectSummary } from '@/request'
|
||||
import { requestClient } from '@/request'
|
||||
import pinia, { useAppStore } from '@/stores'
|
||||
|
||||
function getRequestClient() {
|
||||
const { isWeb } = useAppStore(pinia)
|
||||
if (!isWeb)
|
||||
return requestClient.local
|
||||
|
||||
return requestClient.http
|
||||
}
|
||||
|
||||
export async function getProjectListApi(): Promise<ProjectSummary[]> {
|
||||
return getRequestClient().project.list()
|
||||
}
|
||||
99
packages/core/src/api/runtime.ts
Normal file
99
packages/core/src/api/runtime.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ofetch } from 'ofetch'
|
||||
import { createHttpClient } from '@/request/client'
|
||||
|
||||
/** RPC 请求客户端(指向 chemical-chaos 运算服务,响应格式与 cslab-server 不同,不复用通用拦截器) */
|
||||
const rpcFetcher = ofetch.create({
|
||||
baseURL: '/chemical-chaos',
|
||||
timeout: 600_000,
|
||||
})
|
||||
|
||||
/** cslab-server 请求客户端(execSequence 等走 Django 后端) */
|
||||
const serverFetcher = createHttpClient()
|
||||
|
||||
// ── 通用 RPC 调用 ──
|
||||
|
||||
/** common/ 接口:返回格式为 { message, result } */
|
||||
function rpcCommon<T = unknown>(clazz: string, method: string, kwargs: Record<string, unknown>) {
|
||||
return rpcFetcher<T>('/v1/rpc/common/', {
|
||||
method: 'POST',
|
||||
body: { clazz, method, args: [], kwargs },
|
||||
})
|
||||
}
|
||||
|
||||
/** zero_rpc/ 接口:返回格式为 { status, msg, data },自动解包取 data */
|
||||
async function rpcZero<T = unknown>(method: string, kwargs: Record<string, unknown>) {
|
||||
const res = await rpcFetcher<{ status: number, msg: string, data: T }>('/v1/rpc/zero_rpc/', {
|
||||
method: 'POST',
|
||||
body: { method, kwargs },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── 执行顺序接口(cslab-server) ──
|
||||
|
||||
/** 获取执行顺序(运行前的必要前置调用) */
|
||||
export function getExecSequenceApi(pro: string, callowWay: string) {
|
||||
return serverFetcher('/project/module/execSequence/', {
|
||||
method: 'GET',
|
||||
params: { pro, is_preview: 0, callow_way: callowWay },
|
||||
headers: { PROJECT: pro },
|
||||
})
|
||||
}
|
||||
|
||||
// ── 任务管理接口 ──
|
||||
|
||||
export interface AddJobParams {
|
||||
pro: string
|
||||
addressee: number
|
||||
callow_way: 'steady' | 'dynamic' | 'design' | 'chemicalPrinciple'
|
||||
is_only_checked?: boolean
|
||||
is_custom_sequence?: boolean
|
||||
is_steady?: boolean
|
||||
need_converge?: number
|
||||
current_origin?: string
|
||||
is_debug?: boolean
|
||||
cal_label?: string
|
||||
pk?: string[]
|
||||
}
|
||||
|
||||
export interface AddJobResult {
|
||||
message: string
|
||||
result: {
|
||||
job: { id: string }
|
||||
msg?: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加运行任务 */
|
||||
export function addJobApi(params: AddJobParams) {
|
||||
return rpcCommon<AddJobResult>(
|
||||
'agent.rpc_client.run.run',
|
||||
'Run.add_job',
|
||||
params as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
/** 暂停任务 */
|
||||
export function pauseJobApi(taskId: string, addressee: number) {
|
||||
return rpcZero('pause_cal_job', { task_id: taskId, addressee })
|
||||
}
|
||||
|
||||
/** 继续运行 */
|
||||
export function resumeJobApi(taskId: string, addressee: number) {
|
||||
return rpcZero('unpause_cal_job', { task_id: taskId, addressee })
|
||||
}
|
||||
|
||||
/** 停止任务 */
|
||||
export function stopJobApi(taskId: string, addressee: number) {
|
||||
return rpcZero('exit_cal_job', { task_id: taskId, addressee })
|
||||
}
|
||||
|
||||
/** 获取项目下的任务 */
|
||||
export function getProjectJobApi(addressee: number, pro: string) {
|
||||
return rpcZero<{ state2: string | null, task: string }>('get_user_job', { addressee, pro })
|
||||
}
|
||||
|
||||
/** 全量推送数据 */
|
||||
export function pushFullDataApi(taskId: string, addressee: number) {
|
||||
return rpcZero('push_full_cal_data', { task_id: taskId, addressee })
|
||||
}
|
||||
BIN
packages/core/src/assets/fonts/Oxanium.woff2
Normal file
BIN
packages/core/src/assets/fonts/Oxanium.woff2
Normal file
Binary file not shown.
6
packages/core/src/assets/fonts/index.css
Normal file
6
packages/core/src/assets/fonts/index.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'Oxanium';
|
||||
src: url('./Oxanium.woff2') format('woff2'),
|
||||
url('./Oxanium.woff') format('woff');
|
||||
font-weight: 400;
|
||||
}
|
||||
BIN
packages/core/src/assets/images/demo.png
Normal file
BIN
packages/core/src/assets/images/demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
BIN
packages/core/src/assets/images/demo1.png
Normal file
BIN
packages/core/src/assets/images/demo1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 708 KiB |
227
packages/core/src/assets/styles/element-plus-theme.scss
Normal file
227
packages/core/src/assets/styles/element-plus-theme.scss
Normal file
@@ -0,0 +1,227 @@
|
||||
@use "sass:color";
|
||||
@use "sass:math";
|
||||
|
||||
/* Element Plus 主题变量覆盖 */
|
||||
/* 基于 Element UI 变量转换为 Element Plus CSS Variables */
|
||||
|
||||
/* Transition
|
||||
-------------------------- */
|
||||
$--all-transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !default;
|
||||
$--fade-transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1) !default;
|
||||
$--fade-linear-transition: opacity 200ms linear !default;
|
||||
$--md-fade-transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 300ms cubic-bezier(0.23, 1, 0.32, 1) !default;
|
||||
$--border-transition-base: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1) !default;
|
||||
$--color-transition-base: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1) !default;
|
||||
|
||||
/* Color
|
||||
-------------------------- */
|
||||
$--color-primary: #1677ff !default;
|
||||
$--color-white: #ffffff !default;
|
||||
$--color-black: #000000 !default;
|
||||
|
||||
// Primary color variants
|
||||
$--color-primary-light-1: color.mix($--color-white, $--color-primary, 10%) !default;
|
||||
$--color-primary-light-2: color.mix($--color-white, $--color-primary, 20%) !default;
|
||||
$--color-primary-light-3: color.mix($--color-white, $--color-primary, 30%) !default;
|
||||
$--color-primary-light-4: color.mix($--color-white, $--color-primary, 40%) !default;
|
||||
$--color-primary-light-5: color.mix($--color-white, $--color-primary, 50%) !default;
|
||||
$--color-primary-light-6: color.mix($--color-white, $--color-primary, 60%) !default;
|
||||
$--color-primary-light-7: color.mix($--color-white, $--color-primary, 70%) !default;
|
||||
$--color-primary-light-8: color.mix($--color-white, $--color-primary, 80%) !default;
|
||||
$--color-primary-light-9: color.mix($--color-white, $--color-primary, 90%) !default;
|
||||
|
||||
// Functional colors
|
||||
$--color-success: #0ac05e !default;
|
||||
$--color-warning: #faad14 !default;
|
||||
$--color-danger: #ff4d4f !default;
|
||||
$--color-info: #8c8c8c !default;
|
||||
|
||||
$--color-success-light: color.mix($--color-white, $--color-success, 80%) !default;
|
||||
$--color-warning-light: color.mix($--color-white, $--color-warning, 80%) !default;
|
||||
$--color-danger-light: color.mix($--color-white, $--color-danger, 80%) !default;
|
||||
$--color-info-light: color.mix($--color-white, $--color-info, 80%) !default;
|
||||
|
||||
$--color-success-lighter: color.mix($--color-white, $--color-success, 90%) !default;
|
||||
$--color-warning-lighter: color.mix($--color-white, $--color-warning, 90%) !default;
|
||||
$--color-danger-lighter: color.mix($--color-white, $--color-danger, 90%) !default;
|
||||
$--color-info-lighter: color.mix($--color-white, $--color-info, 90%) !default;
|
||||
|
||||
// Text colors
|
||||
$--color-text-primary: #262626 !default;
|
||||
$--color-text-regular: #595959 !default;
|
||||
$--color-text-secondary: #8c8c8c !default;
|
||||
$--color-text-placeholder: #bfbfbf !default;
|
||||
|
||||
// Border colors
|
||||
$--border-color-base: #d9d9d9 !default;
|
||||
$--border-color-light: #e8e8e8 !default;
|
||||
$--border-color-lighter: #e8e8e8 !default;
|
||||
$--border-color-extra-light: #f5f5f5 !default;
|
||||
|
||||
// Background
|
||||
$--background-color-base: #f5f5f5 !default;
|
||||
|
||||
/* Border
|
||||
-------------------------- */
|
||||
$--border-width-base: 1px !default;
|
||||
$--border-style-base: solid !default;
|
||||
$--border-color-hover: $--color-text-placeholder !default;
|
||||
$--border-base: $--border-width-base $--border-style-base $--border-color-base !default;
|
||||
$--border-radius-base: 6px !default;
|
||||
$--border-radius-small: 4px !default;
|
||||
$--border-radius-circle: 100% !default;
|
||||
$--border-radius-zero: 0 !default;
|
||||
|
||||
/* Box-shadow
|
||||
-------------------------- */
|
||||
$--box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.08) !default;
|
||||
$--box-shadow-dark: 0 6px 16px rgba(0, 0, 0, 0.12) !default;
|
||||
$--box-shadow-light: 0 4px 12px rgba(0, 0, 0, 0.1) !default;
|
||||
|
||||
/* Typography
|
||||
-------------------------- */
|
||||
$--font-size-extra-large: 20px !default;
|
||||
$--font-size-large: 18px !default;
|
||||
$--font-size-medium: 16px !default;
|
||||
$--font-size-base: 14px !default;
|
||||
$--font-size-small: 13px !default;
|
||||
$--font-size-extra-small: 12px !default;
|
||||
$--font-weight-primary: 500 !default;
|
||||
$--font-weight-secondary: 100 !default;
|
||||
$--font-line-height-primary: 24px !default;
|
||||
$--font-line-height-secondary: 16px !default;
|
||||
|
||||
/* z-index
|
||||
-------------------------- */
|
||||
$--index-normal: 1 !default;
|
||||
$--index-top: 1000 !default;
|
||||
$--index-popper: 2000 !default;
|
||||
|
||||
/* Disabled
|
||||
-------------------------- */
|
||||
$--disabled-fill-base: $--background-color-base !default;
|
||||
$--disabled-color-base: $--color-text-placeholder !default;
|
||||
$--disabled-border-base: $--border-color-light !default;
|
||||
|
||||
/* Element Plus CSS Variables Override
|
||||
-------------------------- */
|
||||
:root {
|
||||
// Colors
|
||||
--el-color-primary: #{$--color-primary};
|
||||
--el-color-primary-light-3: #{$--color-primary-light-3};
|
||||
--el-color-primary-light-5: #{$--color-primary-light-5};
|
||||
--el-color-primary-light-7: #{$--color-primary-light-7};
|
||||
--el-color-primary-light-8: #{$--color-primary-light-8};
|
||||
--el-color-primary-light-9: #{$--color-primary-light-9};
|
||||
--el-color-primary-dark-2: #{color.mix($--color-black, $--color-primary, 20%)};
|
||||
|
||||
--el-color-success: #{$--color-success};
|
||||
--el-color-success-light-3: #{color.mix($--color-white, $--color-success, 30%)};
|
||||
--el-color-success-light-5: #{color.mix($--color-white, $--color-success, 50%)};
|
||||
--el-color-success-light-7: #{color.mix($--color-white, $--color-success, 70%)};
|
||||
--el-color-success-light-8: #{color.mix($--color-white, $--color-success, 80%)};
|
||||
--el-color-success-light-9: #{color.mix($--color-white, $--color-success, 90%)};
|
||||
|
||||
--el-color-warning: #{$--color-warning};
|
||||
--el-color-warning-light-3: #{color.mix($--color-white, $--color-warning, 30%)};
|
||||
--el-color-warning-light-5: #{color.mix($--color-white, $--color-warning, 50%)};
|
||||
--el-color-warning-light-7: #{color.mix($--color-white, $--color-warning, 70%)};
|
||||
--el-color-warning-light-8: #{color.mix($--color-white, $--color-warning, 80%)};
|
||||
--el-color-warning-light-9: #{color.mix($--color-white, $--color-warning, 90%)};
|
||||
|
||||
--el-color-danger: #{$--color-danger};
|
||||
--el-color-danger-light-3: #{color.mix($--color-white, $--color-danger, 30%)};
|
||||
--el-color-danger-light-5: #{color.mix($--color-white, $--color-danger, 50%)};
|
||||
--el-color-danger-light-7: #{color.mix($--color-white, $--color-danger, 70%)};
|
||||
--el-color-danger-light-8: #{color.mix($--color-white, $--color-danger, 80%)};
|
||||
--el-color-danger-light-9: #{color.mix($--color-white, $--color-danger, 90%)};
|
||||
|
||||
--el-color-info: #{$--color-info};
|
||||
--el-color-info-light-3: #{color.mix($--color-white, $--color-info, 30%)};
|
||||
--el-color-info-light-5: #{color.mix($--color-white, $--color-info, 50%)};
|
||||
--el-color-info-light-7: #{color.mix($--color-white, $--color-info, 70%)};
|
||||
--el-color-info-light-8: #{color.mix($--color-white, $--color-info, 80%)};
|
||||
--el-color-info-light-9: #{color.mix($--color-white, $--color-info, 90%)};
|
||||
|
||||
// Text colors
|
||||
--el-text-color-primary: #{$--color-text-primary};
|
||||
--el-text-color-regular: #{$--color-text-regular};
|
||||
--el-text-color-secondary: #{$--color-text-secondary};
|
||||
--el-text-color-placeholder: #{$--color-text-placeholder};
|
||||
--el-text-color-disabled: #{$--disabled-color-base};
|
||||
|
||||
// Border colors
|
||||
--el-border-color: #{$--border-color-base};
|
||||
--el-border-color-light: #{$--border-color-light};
|
||||
--el-border-color-lighter: #{$--border-color-lighter};
|
||||
--el-border-color-extra-light: #{$--border-color-extra-light};
|
||||
--el-border-color-dark: #{color.mix($--color-black, $--border-color-base, 20%)};
|
||||
--el-border-color-darker: #{color.mix($--color-black, $--border-color-base, 40%)};
|
||||
|
||||
// Fill colors
|
||||
--el-fill-color: #{$--background-color-base};
|
||||
--el-fill-color-light: #{color.mix($--color-white, $--background-color-base, 30%)};
|
||||
--el-fill-color-lighter: #{color.mix($--color-white, $--background-color-base, 50%)};
|
||||
--el-fill-color-extra-light: #{color.mix($--color-white, $--background-color-base, 70%)};
|
||||
--el-fill-color-dark: #{color.mix($--color-black, $--background-color-base, 10%)};
|
||||
--el-fill-color-darker: #{color.mix($--color-black, $--background-color-base, 20%)};
|
||||
--el-fill-color-blank: #{$--color-white};
|
||||
|
||||
// Background colors
|
||||
--el-bg-color: #{$--color-white};
|
||||
--el-bg-color-page: #{$--background-color-base};
|
||||
--el-bg-color-overlay: #{$--color-white};
|
||||
|
||||
// Border
|
||||
--el-border-width: #{$--border-width-base};
|
||||
--el-border-style: #{$--border-style-base};
|
||||
--el-border-radius-base: #{$--border-radius-base};
|
||||
--el-border-radius-small: #{$--border-radius-small};
|
||||
--el-border-radius-round: 20px;
|
||||
--el-border-radius-circle: #{$--border-radius-circle};
|
||||
|
||||
// Box-shadow
|
||||
--el-box-shadow: #{$--box-shadow-base};
|
||||
--el-box-shadow-light: #{$--box-shadow-light};
|
||||
--el-box-shadow-lighter: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--el-box-shadow-dark: #{$--box-shadow-dark};
|
||||
|
||||
// Typography
|
||||
--el-font-size-extra-large: #{$--font-size-extra-large};
|
||||
--el-font-size-large: #{$--font-size-large};
|
||||
--el-font-size-medium: #{$--font-size-medium};
|
||||
--el-font-size-base: #{$--font-size-base};
|
||||
--el-font-size-small: #{$--font-size-small};
|
||||
--el-font-size-extra-small: #{$--font-size-extra-small};
|
||||
|
||||
--el-font-weight-primary: #{$--font-weight-primary};
|
||||
--el-font-line-height-primary: #{$--font-line-height-primary};
|
||||
|
||||
// z-index
|
||||
--el-index-normal: #{$--index-normal};
|
||||
--el-index-top: #{$--index-top};
|
||||
--el-index-popper: #{$--index-popper};
|
||||
|
||||
// Transition
|
||||
--el-transition-duration: 0.3s;
|
||||
--el-transition-duration-fast: 0.2s;
|
||||
--el-transition-function-ease-in-out-bezier: cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
--el-transition-function-fast-bezier: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--el-transition-all: #{$--all-transition};
|
||||
--el-transition-fade: #{$--fade-transition};
|
||||
--el-transition-fade-linear: #{$--fade-linear-transition};
|
||||
--el-transition-md-fade: #{$--md-fade-transition};
|
||||
--el-transition-border: #{$--border-transition-base};
|
||||
--el-transition-color: #{$--color-transition-base};
|
||||
|
||||
// Component size
|
||||
--el-component-size-large: 40px;
|
||||
--el-component-size: 32px;
|
||||
--el-component-size-small: 24px;
|
||||
|
||||
// Disabled
|
||||
--el-disabled-bg-color: #{$--disabled-fill-base};
|
||||
--el-disabled-text-color: #{$--disabled-color-base};
|
||||
--el-disabled-border-color: #{$--disabled-border-base};
|
||||
}
|
||||
41
packages/core/src/assets/styles/index.scss
Normal file
41
packages/core/src/assets/styles/index.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
@use './variables.scss' as *;
|
||||
@use './element-plus-theme.scss';
|
||||
@import url('../fonts/index.css');
|
||||
|
||||
/* Reset or Base styles */
|
||||
body {
|
||||
background-color: $bg-color-page;
|
||||
color: $text-color-primary;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Scrollbar customization */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: $border-color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: $text-color-secondary;
|
||||
}
|
||||
|
||||
/* DCS 画布组件动画 */
|
||||
@keyframes dcs-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
.dcs-blink {
|
||||
animation: dcs-blink 1s ease-in-out infinite;
|
||||
}
|
||||
18
packages/core/src/assets/styles/variables.scss
Normal file
18
packages/core/src/assets/styles/variables.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
/* Global SCSS Variables */
|
||||
$primary-color: #1677ff;
|
||||
$success-color: #0ac05e;
|
||||
$warning-color: #faad14;
|
||||
$danger-color: #ff4d4f;
|
||||
$info-color: #8c8c8c;
|
||||
|
||||
$text-color-primary: #262626;
|
||||
$text-color-regular: #595959;
|
||||
$text-color-secondary: #8c8c8c;
|
||||
$text-color-placeholder: #bfbfbf;
|
||||
|
||||
$border-color: #d9d9d9;
|
||||
$border-color-light: #e8e8e8;
|
||||
$border-color-lighter: #e8e8e8;
|
||||
|
||||
$bg-color: #ffffff;
|
||||
$bg-color-page: #f5f5f5;
|
||||
11
packages/core/src/auto-imports.d.ts
vendored
Normal file
11
packages/core/src/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
|
||||
}
|
||||
23
packages/core/src/bootstrap/bridge.ts
Normal file
23
packages/core/src/bootstrap/bridge.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { IPlatformBridge } from '@cslab-dcs/bridge'
|
||||
import { bridge as defaultBridge } from '@cslab-dcs/bridge'
|
||||
import { inject, onMounted } from 'vue'
|
||||
import pinia from '@/stores'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
export function useBridge() {
|
||||
const bridge = inject<IPlatformBridge>('bridge', defaultBridge)
|
||||
|
||||
const appStore = useAppStore(pinia)
|
||||
|
||||
onMounted(async () => {
|
||||
const platformInfo = await bridge.system.getPlatformInfo()
|
||||
appStore.setPlatformInfo(platformInfo)
|
||||
|
||||
const appVersion = await bridge.system.getAppVersion()
|
||||
appStore.setAppVersion(appVersion)
|
||||
})
|
||||
|
||||
return {
|
||||
bridge,
|
||||
}
|
||||
}
|
||||
64
packages/core/src/bootstrap/editor.ts
Normal file
64
packages/core/src/bootstrap/editor.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
import pinia, { useCanvasStore, useLayerStore, useProjectStore } from '@/stores'
|
||||
|
||||
export function useEditorWatchEffect() {
|
||||
const projectStore = useProjectStore(pinia)
|
||||
const { projectId } = storeToRefs(projectStore)
|
||||
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
const { canvasId } = storeToRefs(canvasStore)
|
||||
|
||||
const layerStore = useLayerStore(pinia)
|
||||
|
||||
function resetCanvas() {
|
||||
canvasStore.setCanvasId(null)
|
||||
canvasStore.setCanvasList([])
|
||||
}
|
||||
|
||||
async function refreshCanvasAndLayer(preferredCanvasId?: string | null) {
|
||||
const canvases = await canvasStore.getCanvasList()
|
||||
const selectedCanvas = preferredCanvasId
|
||||
? canvases.find(canvas => canvas.id === preferredCanvasId)
|
||||
: undefined
|
||||
const nextCanvas = selectedCanvas || canvases[0] || null
|
||||
canvasStore.setCanvasId(nextCanvas?.id || null)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => projectId.value,
|
||||
async (nextProjectId) => {
|
||||
// 切换项目时重置画布和图层状态
|
||||
const preferredCanvasId = canvasId.value
|
||||
resetCanvas()
|
||||
|
||||
if (!nextProjectId) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取新项目的项目信息,更新项目名称等相关状态
|
||||
await projectStore.getProjectInfo()
|
||||
|
||||
// 获取新项目的画布列表,并尽量保留 URL / store 中指定的画布
|
||||
await refreshCanvasAndLayer(preferredCanvasId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function resetLayer() {
|
||||
layerStore.setLayerId('')
|
||||
layerStore.replaceLayers([])
|
||||
}
|
||||
|
||||
watch(
|
||||
() => canvasId.value,
|
||||
async () => {
|
||||
// 切换画布时重置图层状态
|
||||
resetLayer()
|
||||
|
||||
// 获取新画布的信息
|
||||
await canvasStore.getCanvasInfo()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
3
packages/core/src/bootstrap/index.ts
Normal file
3
packages/core/src/bootstrap/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './bridge'
|
||||
export * from './editor'
|
||||
export * from './route-state'
|
||||
52
packages/core/src/bootstrap/route-state.ts
Normal file
52
packages/core/src/bootstrap/route-state.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getRouteCanvasId, getRouteProjectId, mergeRouteStateQuery, shouldAttachCanvasId } from '@/router/route-state'
|
||||
import { useCanvasStore, useProjectStore } from '@/stores'
|
||||
|
||||
export function useRouteStateSync() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { projectId } = storeToRefs(projectStore)
|
||||
const { canvasId } = storeToRefs(canvasStore)
|
||||
|
||||
watch(
|
||||
[projectId, canvasId, () => route.fullPath],
|
||||
async () => {
|
||||
const nextProjectId = projectId.value || ''
|
||||
const nextCanvasId = canvasId.value || ''
|
||||
const routeProjectId = getRouteProjectId(route.query)
|
||||
const routeCanvasId = getRouteCanvasId(route.query)
|
||||
const includeCanvasId = shouldAttachCanvasId(route)
|
||||
|
||||
// 首次刷新时优先从当前 URL 恢复上下文,避免 store 尚未就绪时把 query 擦掉。
|
||||
if (!nextProjectId && routeProjectId) {
|
||||
projectStore.setProjectId(routeProjectId)
|
||||
return
|
||||
}
|
||||
|
||||
if (includeCanvasId && !nextCanvasId && routeCanvasId) {
|
||||
canvasStore.setCanvasId(routeCanvasId)
|
||||
return
|
||||
}
|
||||
|
||||
if (routeProjectId === nextProjectId && routeCanvasId === nextCanvasId) {
|
||||
return
|
||||
}
|
||||
|
||||
await router.replace({
|
||||
path: route.path,
|
||||
query: mergeRouteStateQuery(route.query, {
|
||||
projectId: nextProjectId,
|
||||
canvasId: nextCanvasId,
|
||||
}, { includeCanvasId }),
|
||||
hash: route.hash,
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
41
packages/core/src/components.d.ts
vendored
Normal file
41
packages/core/src/components.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { createCanvasApi, updateCanvasApi } from '@/api'
|
||||
import { useCanvasStore } from '@/stores'
|
||||
import { useCanvasPageInject } from '../context'
|
||||
|
||||
const { showPageDialog, editingPageId, changePageDialogVisible } = useCanvasPageInject()
|
||||
|
||||
const isEdit = computed(() => !!editingPageId.value)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { canvasList } = storeToRefs(canvasStore)
|
||||
|
||||
const pageDialogName = ref('')
|
||||
const pageDialogDescription = ref('')
|
||||
const isSubmittingPageDialog = ref(false)
|
||||
|
||||
function getNextCanvasName() {
|
||||
const baseName = 'DCS图'
|
||||
const existingNames = new Set(canvasList.value.map(page => page.name))
|
||||
if (!existingNames.has(baseName)) {
|
||||
return baseName + (canvasList.value.length + 1)
|
||||
}
|
||||
|
||||
let counter = 2
|
||||
let nextName = `${baseName}${counter}`
|
||||
while (existingNames.has(nextName)) {
|
||||
counter += 1
|
||||
nextName = `${baseName}${counter}`
|
||||
}
|
||||
|
||||
return nextName
|
||||
}
|
||||
|
||||
watch(() => showPageDialog.value, (next) => {
|
||||
if (next && !isEdit.value) {
|
||||
pageDialogName.value = getNextCanvasName()
|
||||
}
|
||||
})
|
||||
|
||||
function onPageDialogClosed() {
|
||||
pageDialogName.value = ''
|
||||
pageDialogDescription.value = ''
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
async function submitPageDialog() {
|
||||
const name = pageDialogName.value.trim()
|
||||
if (!name) {
|
||||
ElMessage.warning('请输入画布名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (isSubmittingPageDialog.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmittingPageDialog.value = true
|
||||
try {
|
||||
if (!isEdit.value) {
|
||||
const createdCanvas = await createCanvasApi({ name, description: pageDialogDescription.value })
|
||||
await canvasStore.getCanvasList()
|
||||
changePageDialogVisible(false)
|
||||
await router.push({
|
||||
path: route.path,
|
||||
query: {
|
||||
...route.query,
|
||||
canvasId: createdCanvas.id,
|
||||
},
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
return
|
||||
}
|
||||
|
||||
const pageId = editingPageId.value
|
||||
if (!pageId) {
|
||||
return
|
||||
}
|
||||
|
||||
await updateCanvasApi({
|
||||
id: pageId,
|
||||
name,
|
||||
description: pageDialogDescription.value,
|
||||
})
|
||||
await canvasStore.getCanvasList()
|
||||
changePageDialogVisible(false)
|
||||
ElMessage.success('修改成功')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[canvas] failed to submit page dialog', error)
|
||||
ElMessage.error(isEdit.value ? '修改失败' : '创建失败')
|
||||
}
|
||||
finally {
|
||||
isSubmittingPageDialog.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="showPageDialog" :title="isEdit ? '重命名DCS图' : '添加DCS图'" width="420px"
|
||||
@closed="onPageDialogClosed"
|
||||
>
|
||||
<el-form label-width="60px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="pageDialogName" maxlength="64" placeholder="请输入DCS图名称" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述">
|
||||
<el-input
|
||||
v-model="pageDialogDescription"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入描述(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button :disabled="isSubmittingPageDialog" @click="changePageDialogVisible(false)">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="isSubmittingPageDialog" @click="submitPageDialog">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,779 @@
|
||||
import type { Layer } from '../types'
|
||||
import type { RectLike } from '../utils'
|
||||
import { createLayerGroupId, getLayerGroupMeta, withLayerGroupMeta } from '../grouping'
|
||||
import { rectIntersects } from '../utils'
|
||||
|
||||
export interface LayerTransformResult {
|
||||
changed: boolean
|
||||
nextLayers: Layer[]
|
||||
groupId?: string | null
|
||||
}
|
||||
|
||||
export interface LayerOrderItem {
|
||||
kind: 'group' | 'component'
|
||||
id: string
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export type LayerMovePlacement = 'before' | 'after' | 'into'
|
||||
export type LayerMoveEdge = 'front' | 'back'
|
||||
export type LayerMoveDirection = 'forward' | 'backward'
|
||||
|
||||
const GROUP_NAME_PATTERN = /^分组\s*(\d+)$/
|
||||
|
||||
function uniqueLayerIds(layerIds: string[]) {
|
||||
return [...new Set(layerIds)].filter(Boolean)
|
||||
}
|
||||
|
||||
export function canGroupLayersByIds(layers: Layer[], layerIds: string[]) {
|
||||
const selectedIds = uniqueLayerIds(layerIds)
|
||||
if (selectedIds.length < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedLayers = layers.filter(layer => selectedIds.includes(layer.id))
|
||||
if (selectedLayers.length !== selectedIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return selectedLayers.every(layer => !getLayerGroupMeta(layer))
|
||||
}
|
||||
|
||||
function isSameOrder(left: Layer[], right: Layer[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
return left.every((item, index) => item.id === right[index]?.id)
|
||||
}
|
||||
|
||||
interface LayerOrderBlock {
|
||||
kind: 'group' | 'component'
|
||||
id: string
|
||||
groupId?: string
|
||||
layerIds: string[]
|
||||
}
|
||||
|
||||
interface OrderedEntryWithBounds<T> {
|
||||
entry: T
|
||||
bounds: RectLike
|
||||
}
|
||||
|
||||
function resolveOrderItemKey(item: LayerOrderItem | LayerOrderBlock) {
|
||||
return item.kind === 'group'
|
||||
? `group:${item.groupId || item.id}`
|
||||
: item.id
|
||||
}
|
||||
|
||||
function resolveOrderItemParentKey(item: LayerOrderItem) {
|
||||
if (item.kind === 'group') {
|
||||
return 'root'
|
||||
}
|
||||
return item.groupId ? `group:${item.groupId}` : 'root'
|
||||
}
|
||||
|
||||
function normalizeLayerOrderItems(items: LayerOrderItem[]) {
|
||||
const uniqueItems: LayerOrderItem[] = []
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
const key = resolveOrderItemKey(item)
|
||||
if (seenKeys.has(key)) {
|
||||
continue
|
||||
}
|
||||
seenKeys.add(key)
|
||||
uniqueItems.push(item)
|
||||
}
|
||||
|
||||
if (!uniqueItems.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parentKey = resolveOrderItemParentKey(uniqueItems[0])
|
||||
// 只允许同一层级内一起移动,避免根节点和组内节点混排造成顺序错乱。
|
||||
if (uniqueItems.some(item => resolveOrderItemParentKey(item) !== parentKey)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
items: uniqueItems,
|
||||
parentKey,
|
||||
selectedKeys: new Set(uniqueItems.map(item => resolveOrderItemKey(item))),
|
||||
}
|
||||
}
|
||||
|
||||
function buildLayerOrderBlocks(layers: Layer[]) {
|
||||
const groupBlocks = new Map<string, LayerOrderBlock>()
|
||||
const blocks: LayerOrderBlock[] = []
|
||||
|
||||
layers.forEach((layer) => {
|
||||
const group = getLayerGroupMeta(layer)
|
||||
if (!group) {
|
||||
blocks.push({
|
||||
kind: 'component',
|
||||
id: layer.id,
|
||||
layerIds: [layer.id],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const existingBlock = groupBlocks.get(group.groupId)
|
||||
if (existingBlock) {
|
||||
// 分组在图层排序里视为一个整体块,内部成员顺序单独维护。
|
||||
existingBlock.layerIds.push(layer.id)
|
||||
return
|
||||
}
|
||||
|
||||
const nextBlock: LayerOrderBlock = {
|
||||
kind: 'group',
|
||||
id: `group:${group.groupId}`,
|
||||
groupId: group.groupId,
|
||||
layerIds: [layer.id],
|
||||
}
|
||||
groupBlocks.set(group.groupId, nextBlock)
|
||||
blocks.push(nextBlock)
|
||||
})
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
function mapLayersById(layers: Layer[]) {
|
||||
return new Map(layers.map(layer => [layer.id, layer] as const))
|
||||
}
|
||||
|
||||
function flattenBlocksToLayers(layers: Layer[], blocks: LayerOrderBlock[]) {
|
||||
const layerMap = mapLayersById(layers)
|
||||
return blocks.flatMap(block => block.layerIds.map(layerId => layerMap.get(layerId)).filter((layer): layer is Layer => Boolean(layer)))
|
||||
}
|
||||
|
||||
function resolveLayerBounds(layer: Layer): RectLike {
|
||||
return {
|
||||
x: layer.x,
|
||||
y: layer.y,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRectBounds(boundsList: RectLike[]) {
|
||||
const [firstBound, ...restBounds] = boundsList
|
||||
if (!firstBound) {
|
||||
return null
|
||||
}
|
||||
|
||||
let left = firstBound.x
|
||||
let top = firstBound.y
|
||||
let right = firstBound.x + firstBound.width
|
||||
let bottom = firstBound.y + firstBound.height
|
||||
|
||||
restBounds.forEach((bound) => {
|
||||
left = Math.min(left, bound.x)
|
||||
top = Math.min(top, bound.y)
|
||||
right = Math.max(right, bound.x + bound.width)
|
||||
bottom = Math.max(bottom, bound.y + bound.height)
|
||||
})
|
||||
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBlockBounds(layers: Layer[], block: LayerOrderBlock) {
|
||||
const layerMap = mapLayersById(layers)
|
||||
const boundsList = block.layerIds
|
||||
.map(layerId => layerMap.get(layerId))
|
||||
.filter((layer): layer is Layer => Boolean(layer))
|
||||
.map(resolveLayerBounds)
|
||||
|
||||
return mergeRectBounds(boundsList)
|
||||
}
|
||||
|
||||
function moveSelectedEntriesToEdge<T>(
|
||||
items: T[],
|
||||
isSelected: (item: T) => boolean,
|
||||
edge: LayerMoveEdge,
|
||||
) {
|
||||
const selected: T[] = []
|
||||
const unselected: T[] = []
|
||||
|
||||
items.forEach((item) => {
|
||||
if (isSelected(item)) {
|
||||
selected.push(item)
|
||||
return
|
||||
}
|
||||
unselected.push(item)
|
||||
})
|
||||
|
||||
if (!selected.length || !unselected.length) {
|
||||
return items
|
||||
}
|
||||
|
||||
return edge === 'back'
|
||||
? [...selected, ...unselected]
|
||||
: [...unselected, ...selected]
|
||||
}
|
||||
|
||||
function moveSelectedEntriesByStep<T>(
|
||||
items: T[],
|
||||
isSelected: (item: T) => boolean,
|
||||
direction: LayerMoveDirection,
|
||||
) {
|
||||
const nextItems = [...items]
|
||||
|
||||
if (direction === 'forward') {
|
||||
for (let index = nextItems.length - 2; index >= 0; index -= 1) {
|
||||
if (!isSelected(nextItems[index]) || isSelected(nextItems[index + 1])) {
|
||||
continue
|
||||
}
|
||||
;[nextItems[index], nextItems[index + 1]] = [nextItems[index + 1], nextItems[index]]
|
||||
}
|
||||
return nextItems
|
||||
}
|
||||
|
||||
for (let index = 1; index < nextItems.length; index += 1) {
|
||||
if (!isSelected(nextItems[index]) || isSelected(nextItems[index - 1])) {
|
||||
continue
|
||||
}
|
||||
;[nextItems[index - 1], nextItems[index]] = [nextItems[index], nextItems[index - 1]]
|
||||
}
|
||||
|
||||
return nextItems
|
||||
}
|
||||
|
||||
function moveSelectedEntriesAroundTarget<T>(
|
||||
items: T[],
|
||||
isSelected: (item: T) => boolean,
|
||||
targetIndex: number,
|
||||
direction: LayerMoveDirection,
|
||||
) {
|
||||
const nextItems = [...items]
|
||||
const selectedEntries = nextItems.filter(isSelected)
|
||||
if (!selectedEntries.length || targetIndex < 0) {
|
||||
return items
|
||||
}
|
||||
|
||||
const remainingItems = nextItems.filter(item => !isSelected(item))
|
||||
const targetEntry = items[targetIndex]
|
||||
const nextTargetIndex = remainingItems.findIndex(item => item === targetEntry)
|
||||
if (nextTargetIndex < 0) {
|
||||
return items
|
||||
}
|
||||
|
||||
const insertionIndex = direction === 'forward' ? nextTargetIndex + 1 : nextTargetIndex
|
||||
remainingItems.splice(insertionIndex, 0, ...selectedEntries)
|
||||
return remainingItems
|
||||
}
|
||||
|
||||
function findClosestOverlapTargetIndex<T>(
|
||||
entries: Array<OrderedEntryWithBounds<T>>,
|
||||
isSelected: (entry: T) => boolean,
|
||||
direction: LayerMoveDirection,
|
||||
) {
|
||||
const selectedEntries = entries.filter(item => isSelected(item.entry))
|
||||
if (!selectedEntries.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedBounds = selectedEntries.map(item => item.bounds)
|
||||
const searchStart = direction === 'forward'
|
||||
? Math.max(...entries.map((item, index) => (isSelected(item.entry) ? index : -1)))
|
||||
: Math.min(...entries.map((item, index) => (isSelected(item.entry) ? index : Number.POSITIVE_INFINITY)))
|
||||
|
||||
if (!Number.isFinite(searchStart)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (direction === 'forward') {
|
||||
// 优先寻找与选中块几何上相交的目标,移动体验更符合“视觉前后”的直觉。
|
||||
for (let index = searchStart + 1; index < entries.length; index += 1) {
|
||||
const candidate = entries[index]
|
||||
if (isSelected(candidate.entry)) {
|
||||
continue
|
||||
}
|
||||
if (selectedBounds.some(bound => rectIntersects(bound, candidate.bounds))) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
for (let index = searchStart - 1; index >= 0; index -= 1) {
|
||||
const candidate = entries[index]
|
||||
if (isSelected(candidate.entry)) {
|
||||
continue
|
||||
}
|
||||
if (selectedBounds.some(bound => rectIntersects(bound, candidate.bounds))) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveNextGroupName(layers: Layer[]) {
|
||||
// 复用“分组N”序列,避免每次新建都出现随机名。
|
||||
const maxGroupIndex = layers.reduce((max, layer) => {
|
||||
const groupName = getLayerGroupMeta(layer)?.groupName?.trim()
|
||||
if (!groupName) {
|
||||
return max
|
||||
}
|
||||
|
||||
const matched = GROUP_NAME_PATTERN.exec(groupName)
|
||||
if (!matched) {
|
||||
return max
|
||||
}
|
||||
|
||||
const value = Number.parseInt(matched[1], 10)
|
||||
return Number.isFinite(value) ? Math.max(max, value) : max
|
||||
}, 0)
|
||||
|
||||
return `分组${maxGroupIndex + 1}`
|
||||
}
|
||||
|
||||
export function reorderLayersToFront(layers: Layer[], layerIds: string[]): LayerTransformResult {
|
||||
const selectedSet = new Set(uniqueLayerIds(layerIds))
|
||||
if (!selectedSet.size) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const selected: Layer[] = []
|
||||
const unselected: Layer[] = []
|
||||
layers.forEach((layer) => {
|
||||
if (selectedSet.has(layer.id)) {
|
||||
selected.push(layer)
|
||||
return
|
||||
}
|
||||
unselected.push(layer)
|
||||
})
|
||||
|
||||
if (!selected.length) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const nextLayers = [...unselected, ...selected]
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderLayersToBack(layers: Layer[], layerIds: string[]): LayerTransformResult {
|
||||
const selectedSet = new Set(uniqueLayerIds(layerIds))
|
||||
if (!selectedSet.size) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const selected: Layer[] = []
|
||||
const unselected: Layer[] = []
|
||||
layers.forEach((layer) => {
|
||||
if (selectedSet.has(layer.id)) {
|
||||
selected.push(layer)
|
||||
return
|
||||
}
|
||||
unselected.push(layer)
|
||||
})
|
||||
|
||||
if (!selected.length) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const nextLayers = [...selected, ...unselected]
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderLayerBefore(layers: Layer[], sourceId: string, targetId: string): LayerTransformResult {
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const sourceIndex = layers.findIndex(layer => layer.id === sourceId)
|
||||
const targetIndex = layers.findIndex(layer => layer.id === targetId)
|
||||
if (sourceIndex < 0 || targetIndex < 0) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const nextLayers = [...layers]
|
||||
const [sourceLayer] = nextLayers.splice(sourceIndex, 1)
|
||||
if (!sourceLayer) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const nextTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
||||
nextLayers.splice(nextTargetIndex, 0, sourceLayer)
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
// 跨父级移动:组件在根级 / 组之间自由拖拽。
|
||||
function moveLayerOrderItemCrossParent(
|
||||
layers: Layer[],
|
||||
source: LayerOrderItem,
|
||||
target: LayerOrderItem,
|
||||
placement: LayerMovePlacement,
|
||||
): LayerTransformResult {
|
||||
// 只有组件才允许跨组移动,分组整体不能被拖入另一个分组。
|
||||
if (source.kind !== 'component') {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const sourceLayer = layers.find(l => l.id === source.id)
|
||||
if (!sourceLayer) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
// 确定目标分组:into → 进入目标组;拖到组成员旁 → 加入该组;否则 → 脱组到根级。
|
||||
let targetGroupMeta: { groupId: string, groupName: string } | null = null
|
||||
|
||||
if (placement === 'into' && target.kind === 'group' && target.groupId) {
|
||||
const member = layers.find(l => getLayerGroupMeta(l)?.groupId === target.groupId)
|
||||
const meta = member ? getLayerGroupMeta(member) : null
|
||||
if (meta) {
|
||||
targetGroupMeta = { groupId: meta.groupId, groupName: meta.groupName }
|
||||
}
|
||||
}
|
||||
else if (target.kind === 'component' && target.groupId) {
|
||||
const targetLayer = layers.find(l => l.id === target.id)
|
||||
const meta = targetLayer ? getLayerGroupMeta(targetLayer) : null
|
||||
if (meta) {
|
||||
targetGroupMeta = { groupId: meta.groupId, groupName: meta.groupName }
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSource = withLayerGroupMeta(sourceLayer, targetGroupMeta)
|
||||
const withoutSource = layers.filter(l => l.id !== source.id)
|
||||
|
||||
let insertionIndex: number
|
||||
|
||||
if (placement === 'into' && target.kind === 'group' && target.groupId) {
|
||||
// 放入组末尾(数组末端 = 面板中组内最上方 = 最高 z-index)
|
||||
let lastIdx = -1
|
||||
withoutSource.forEach((l, i) => {
|
||||
if (getLayerGroupMeta(l)?.groupId === target.groupId) {
|
||||
lastIdx = i
|
||||
}
|
||||
})
|
||||
insertionIndex = lastIdx >= 0 ? lastIdx + 1 : withoutSource.length
|
||||
}
|
||||
else if (target.kind === 'group' && target.groupId) {
|
||||
// 拖到组头行的 before / after → 根级排序,图层不进组
|
||||
const groupIndices: number[] = []
|
||||
withoutSource.forEach((l, i) => {
|
||||
if (getLayerGroupMeta(l)?.groupId === target.groupId) {
|
||||
groupIndices.push(i)
|
||||
}
|
||||
})
|
||||
if (!groupIndices.length) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
insertionIndex = placement === 'before'
|
||||
? Math.min(...groupIndices)
|
||||
: Math.max(...groupIndices) + 1
|
||||
}
|
||||
else {
|
||||
// 拖到普通组件旁
|
||||
const targetIdx = withoutSource.findIndex(l => l.id === target.id)
|
||||
if (targetIdx < 0) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
insertionIndex = placement === 'before' ? targetIdx : targetIdx + 1
|
||||
}
|
||||
|
||||
const nextLayers = [...withoutSource]
|
||||
nextLayers.splice(insertionIndex, 0, updatedSource)
|
||||
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveLayerOrderItem(
|
||||
layers: Layer[],
|
||||
source: LayerOrderItem,
|
||||
target: LayerOrderItem,
|
||||
placement: LayerMovePlacement,
|
||||
): LayerTransformResult {
|
||||
if (!source.id || !target.id) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const sourceParentKey = resolveOrderItemParentKey(source)
|
||||
const targetParentKey = resolveOrderItemParentKey(target)
|
||||
|
||||
// 跨父级移动或 into 放入分组
|
||||
if (sourceParentKey !== targetParentKey || placement === 'into') {
|
||||
return moveLayerOrderItemCrossParent(layers, source, target, placement)
|
||||
}
|
||||
|
||||
const blocks = buildLayerOrderBlocks(layers)
|
||||
|
||||
if (sourceParentKey === 'root') {
|
||||
// 根层级拖拽时,分组整体和独立图层都作为 block 参与排序。
|
||||
const sourceKey = resolveOrderItemKey(source)
|
||||
const targetKey = resolveOrderItemKey(target)
|
||||
if (sourceKey === targetKey) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const sourceIndex = blocks.findIndex(block => resolveOrderItemKey(block) === sourceKey)
|
||||
const targetIndex = blocks.findIndex(block => resolveOrderItemKey(block) === targetKey)
|
||||
if (sourceIndex < 0 || targetIndex < 0) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const nextBlocks = [...blocks]
|
||||
const [sourceBlock] = nextBlocks.splice(sourceIndex, 1)
|
||||
if (!sourceBlock) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const nextTargetIndex = nextBlocks.findIndex(block => resolveOrderItemKey(block) === targetKey)
|
||||
if (nextTargetIndex < 0) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const insertionIndex = placement === 'before' ? nextTargetIndex : nextTargetIndex + 1
|
||||
nextBlocks.splice(insertionIndex, 0, sourceBlock)
|
||||
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
|
||||
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
if (source.kind !== 'component' || target.kind !== 'component' || !source.groupId || source.groupId !== target.groupId) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const targetGroupIndex = blocks.findIndex(block => block.kind === 'group' && block.groupId === source.groupId)
|
||||
if (targetGroupIndex < 0) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const targetGroupBlock = blocks[targetGroupIndex]
|
||||
const sourceIndex = targetGroupBlock.layerIds.findIndex(layerId => layerId === source.id)
|
||||
const targetIndex = targetGroupBlock.layerIds.findIndex(layerId => layerId === target.id)
|
||||
if (sourceIndex < 0 || targetIndex < 0 || source.id === target.id) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const nextGroupLayerIds = [...targetGroupBlock.layerIds]
|
||||
const [sourceLayerId] = nextGroupLayerIds.splice(sourceIndex, 1)
|
||||
if (!sourceLayerId) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const nextTargetIndex = nextGroupLayerIds.findIndex(layerId => layerId === target.id)
|
||||
if (nextTargetIndex < 0) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const insertionIndex = placement === 'before' ? nextTargetIndex : nextTargetIndex + 1
|
||||
nextGroupLayerIds.splice(insertionIndex, 0, sourceLayerId)
|
||||
const nextBlocks = [...blocks]
|
||||
nextBlocks[targetGroupIndex] = {
|
||||
...targetGroupBlock,
|
||||
layerIds: nextGroupLayerIds,
|
||||
}
|
||||
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
|
||||
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveLayerOrderItemsToEdge(
|
||||
layers: Layer[],
|
||||
selectedItems: LayerOrderItem[],
|
||||
edge: LayerMoveEdge,
|
||||
): LayerTransformResult {
|
||||
const normalized = normalizeLayerOrderItems(selectedItems)
|
||||
if (!normalized) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const blocks = buildLayerOrderBlocks(layers)
|
||||
const isSelectedBlock = (block: LayerOrderBlock) => normalized.selectedKeys.has(resolveOrderItemKey(block))
|
||||
|
||||
if (normalized.parentKey === 'root') {
|
||||
const nextBlocks = moveSelectedEntriesToEdge(blocks, isSelectedBlock, edge)
|
||||
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
const groupId = normalized.items[0]?.groupId
|
||||
if (!groupId) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const targetGroupIndex = blocks.findIndex(block => block.kind === 'group' && block.groupId === groupId)
|
||||
if (targetGroupIndex < 0) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const targetGroupBlock = blocks[targetGroupIndex]
|
||||
const nextGroupLayerIds = moveSelectedEntriesToEdge(
|
||||
targetGroupBlock.layerIds,
|
||||
layerId => normalized.selectedKeys.has(layerId),
|
||||
edge,
|
||||
)
|
||||
const nextBlocks = [...blocks]
|
||||
nextBlocks[targetGroupIndex] = {
|
||||
...targetGroupBlock,
|
||||
layerIds: nextGroupLayerIds,
|
||||
}
|
||||
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
|
||||
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveLayerOrderItemsByStep(
|
||||
layers: Layer[],
|
||||
selectedItems: LayerOrderItem[],
|
||||
direction: LayerMoveDirection,
|
||||
): LayerTransformResult {
|
||||
const normalized = normalizeLayerOrderItems(selectedItems)
|
||||
if (!normalized) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const blocks = buildLayerOrderBlocks(layers)
|
||||
const isSelectedBlock = (block: LayerOrderBlock) => normalized.selectedKeys.has(resolveOrderItemKey(block))
|
||||
|
||||
if (normalized.parentKey === 'root') {
|
||||
// 根层级优先按几何重叠关系找“下一站”,否则退回到纯顺序移动。
|
||||
const rootEntries = blocks
|
||||
.map(block => ({
|
||||
entry: block,
|
||||
bounds: resolveBlockBounds(layers, block),
|
||||
}))
|
||||
.filter((item): item is OrderedEntryWithBounds<LayerOrderBlock> => Boolean(item.bounds))
|
||||
const overlapTargetIndex = findClosestOverlapTargetIndex(rootEntries, isSelectedBlock, direction)
|
||||
const nextBlocks = overlapTargetIndex === null
|
||||
? moveSelectedEntriesByStep(blocks, isSelectedBlock, direction)
|
||||
: moveSelectedEntriesAroundTarget(blocks, isSelectedBlock, overlapTargetIndex, direction)
|
||||
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
const groupId = normalized.items[0]?.groupId
|
||||
if (!groupId) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const targetGroupIndex = blocks.findIndex(block => block.kind === 'group' && block.groupId === groupId)
|
||||
if (targetGroupIndex < 0) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const targetGroupBlock = blocks[targetGroupIndex]
|
||||
const layerMap = mapLayersById(layers)
|
||||
const groupEntries = targetGroupBlock.layerIds
|
||||
.map((layerId) => {
|
||||
const layer = layerMap.get(layerId)
|
||||
if (!layer) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
entry: layerId,
|
||||
bounds: resolveLayerBounds(layer),
|
||||
}
|
||||
})
|
||||
.filter((item): item is OrderedEntryWithBounds<string> => Boolean(item))
|
||||
const overlapTargetIndex = findClosestOverlapTargetIndex(
|
||||
groupEntries,
|
||||
layerId => normalized.selectedKeys.has(layerId),
|
||||
direction,
|
||||
)
|
||||
const nextGroupLayerIds = overlapTargetIndex === null
|
||||
? moveSelectedEntriesByStep(
|
||||
targetGroupBlock.layerIds,
|
||||
layerId => normalized.selectedKeys.has(layerId),
|
||||
direction,
|
||||
)
|
||||
: moveSelectedEntriesAroundTarget(
|
||||
targetGroupBlock.layerIds,
|
||||
layerId => normalized.selectedKeys.has(layerId),
|
||||
overlapTargetIndex,
|
||||
direction,
|
||||
)
|
||||
const nextBlocks = [...blocks]
|
||||
nextBlocks[targetGroupIndex] = {
|
||||
...targetGroupBlock,
|
||||
layerIds: nextGroupLayerIds,
|
||||
}
|
||||
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
|
||||
|
||||
return {
|
||||
changed: !isSameOrder(layers, nextLayers),
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
|
||||
export function groupLayersByIds(layers: Layer[], layerIds: string[], groupName?: string): LayerTransformResult {
|
||||
const selectedIds = uniqueLayerIds(layerIds)
|
||||
if (!canGroupLayersByIds(layers, selectedIds)) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const selectedSet = new Set(selectedIds)
|
||||
const groupId = createLayerGroupId()
|
||||
const nextGroupName = groupName?.trim() || resolveNextGroupName(layers)
|
||||
const selectedLayers = layers
|
||||
.filter(layer => selectedSet.has(layer.id))
|
||||
.map(layer => withLayerGroupMeta(layer, { groupId, groupName: nextGroupName }))
|
||||
if (selectedLayers.length < 1) {
|
||||
return { changed: false, nextLayers: layers }
|
||||
}
|
||||
|
||||
const firstSelectedIndex = layers.findIndex(layer => selectedSet.has(layer.id))
|
||||
const unselectedLayers = layers.filter(layer => !selectedSet.has(layer.id))
|
||||
// 分组后把整组插回首次选中位置,尽量保持用户原有层级感知。
|
||||
const nextLayers = [...unselectedLayers]
|
||||
nextLayers.splice(firstSelectedIndex, 0, ...selectedLayers)
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
nextLayers,
|
||||
groupId,
|
||||
}
|
||||
}
|
||||
|
||||
export function ungroupLayersByIds(layers: Layer[], layerIds?: string[]): LayerTransformResult {
|
||||
const targetSet = layerIds?.length ? new Set(uniqueLayerIds(layerIds)) : null
|
||||
let changed = false
|
||||
|
||||
const nextLayers = layers.map((layer) => {
|
||||
if (targetSet && !targetSet.has(layer.id)) {
|
||||
return layer
|
||||
}
|
||||
if (!getLayerGroupMeta(layer)) {
|
||||
return layer
|
||||
}
|
||||
changed = true
|
||||
return withLayerGroupMeta(layer, null)
|
||||
})
|
||||
|
||||
return {
|
||||
changed,
|
||||
nextLayers,
|
||||
}
|
||||
}
|
||||
543
packages/core/src/components/editor/canvas/composables/layer.ts
Normal file
543
packages/core/src/components/editor/canvas/composables/layer.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import type { PointerPosition } from '../context'
|
||||
import type { Layer } from '../types'
|
||||
import type { LayerMoveDirection, LayerMoveEdge, LayerMovePlacement, LayerOrderItem } from './layer.domain'
|
||||
import { GRID_SIZE } from '@/constants'
|
||||
import { useLayerStore } from '@/stores'
|
||||
import { useCanvasState } from '../context'
|
||||
import { getLayerGroupById, getLayerGroupMeta } from '../grouping'
|
||||
import { clamp, createLayerId, snapToGrid } from '../utils'
|
||||
import {
|
||||
canGroupLayersByIds,
|
||||
groupLayersByIds,
|
||||
moveLayerOrderItem,
|
||||
moveLayerOrderItemsByStep,
|
||||
moveLayerOrderItemsToEdge,
|
||||
reorderLayerBefore,
|
||||
reorderLayersToBack,
|
||||
reorderLayersToFront,
|
||||
ungroupLayersByIds,
|
||||
} from './layer.domain'
|
||||
|
||||
export function useLayerActions() {
|
||||
const { layerList } = useCanvasState()
|
||||
const layerStore = useLayerStore()
|
||||
|
||||
function applyLayerList(nextLayers: Layer[]) {
|
||||
// 图层顺序和分组调整后保留当前选区,避免用户每次操作都丢选中态。
|
||||
layerStore.setLayerList(nextLayers, { preserveSelection: true })
|
||||
}
|
||||
|
||||
function toggleLayerLock(id: string, locked?: boolean) {
|
||||
return layerStore.updateLayerById(id, (item) => {
|
||||
const nextLocked = typeof locked === 'boolean' ? locked : !item.config?.locked
|
||||
return {
|
||||
...item,
|
||||
config: {
|
||||
...item.config,
|
||||
locked: nextLocked,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toggleLayerVisible(id: string, visible?: boolean) {
|
||||
return layerStore.updateLayerById(id, (item) => {
|
||||
const nextVisible = typeof visible === 'boolean' ? visible : item.config?.visible === false
|
||||
return {
|
||||
...item,
|
||||
config: {
|
||||
...item.config,
|
||||
visible: nextVisible,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function bringLayersToFront(layerIds: string[]) {
|
||||
const { changed, nextLayers } = reorderLayersToFront(layerList.value, layerIds)
|
||||
if (!changed) {
|
||||
return false
|
||||
}
|
||||
applyLayerList(nextLayers)
|
||||
return true
|
||||
}
|
||||
|
||||
function sendLayersToBack(layerIds: string[]) {
|
||||
const { changed, nextLayers } = reorderLayersToBack(layerList.value, layerIds)
|
||||
if (!changed) {
|
||||
return false
|
||||
}
|
||||
applyLayerList(nextLayers)
|
||||
return true
|
||||
}
|
||||
|
||||
function moveLayerBefore(sourceId: string, targetId: string) {
|
||||
const { changed, nextLayers } = reorderLayerBefore(layerList.value, sourceId, targetId)
|
||||
if (!changed) {
|
||||
return false
|
||||
}
|
||||
applyLayerList(nextLayers)
|
||||
return true
|
||||
}
|
||||
|
||||
function moveLayerItem(source: LayerOrderItem, target: LayerOrderItem, placement: LayerMovePlacement) {
|
||||
const { changed, nextLayers } = moveLayerOrderItem(layerList.value, source, target, placement)
|
||||
if (!changed) {
|
||||
return false
|
||||
}
|
||||
applyLayerList(nextLayers)
|
||||
return true
|
||||
}
|
||||
|
||||
function moveLayerItemsToEdge(items: LayerOrderItem[], edge: LayerMoveEdge) {
|
||||
const { changed, nextLayers } = moveLayerOrderItemsToEdge(layerList.value, items, edge)
|
||||
if (!changed) {
|
||||
return false
|
||||
}
|
||||
applyLayerList(nextLayers)
|
||||
return true
|
||||
}
|
||||
|
||||
function moveLayerItemsByStep(items: LayerOrderItem[], direction: LayerMoveDirection) {
|
||||
const { changed, nextLayers } = moveLayerOrderItemsByStep(layerList.value, items, direction)
|
||||
if (!changed) {
|
||||
return false
|
||||
}
|
||||
applyLayerList(nextLayers)
|
||||
return true
|
||||
}
|
||||
|
||||
function canMoveLayerItemsToEdge(items: LayerOrderItem[], edge: LayerMoveEdge) {
|
||||
return moveLayerOrderItemsToEdge(layerList.value, items, edge).changed
|
||||
}
|
||||
|
||||
function canMoveLayerItemsByStep(items: LayerOrderItem[], direction: LayerMoveDirection) {
|
||||
return moveLayerOrderItemsByStep(layerList.value, items, direction).changed
|
||||
}
|
||||
|
||||
function canGroupLayers(layerIds: string[]) {
|
||||
return canGroupLayersByIds(layerList.value, layerIds)
|
||||
}
|
||||
|
||||
function groupLayers(layerIds: string[], groupName?: string) {
|
||||
const { changed, nextLayers, groupId } = groupLayersByIds(layerList.value, layerIds, groupName)
|
||||
if (!changed) {
|
||||
return null
|
||||
}
|
||||
applyLayerList(nextLayers)
|
||||
return groupId ?? null
|
||||
}
|
||||
|
||||
function ungroupLayers(layerIds?: string[]) {
|
||||
const { changed, nextLayers } = ungroupLayersByIds(layerList.value, layerIds)
|
||||
if (!changed) {
|
||||
return false
|
||||
}
|
||||
applyLayerList(nextLayers)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
toggleLayerLock,
|
||||
toggleLayerVisible,
|
||||
bringLayersToFront,
|
||||
sendLayersToBack,
|
||||
moveLayerBefore,
|
||||
moveLayerItem,
|
||||
moveLayerItemsToEdge,
|
||||
moveLayerItemsByStep,
|
||||
canMoveLayerItemsToEdge,
|
||||
canMoveLayerItemsByStep,
|
||||
canGroupLayers,
|
||||
groupLayers,
|
||||
ungroupLayers,
|
||||
}
|
||||
}
|
||||
|
||||
export function useLayerSelection() {
|
||||
const { selectedLayerId, selectedLayerIds, selectedGroup, layerList, canvasView } = useCanvasState()
|
||||
const layerStore = useLayerStore()
|
||||
const layerActions = useLayerActions()
|
||||
|
||||
function setSelection(nextIds: string[], primaryId: string | null = nextIds[0] ?? null, options?: { groupId?: string | null }) {
|
||||
// 主选中项和多选数组统一从这里收口,保证面板和舞台状态一致。
|
||||
layerStore.setSelectedLayerIds(nextIds, primaryId ?? '', options)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
layerStore.clearLayerSelection()
|
||||
}
|
||||
|
||||
// 由外部图层面板触发,按组件 ID 精确选中
|
||||
function selectLayerById(id: string) {
|
||||
const exists = layerList.value.some(item => item.id === id)
|
||||
if (!exists) {
|
||||
return false
|
||||
}
|
||||
setSelection([id], id)
|
||||
return true
|
||||
}
|
||||
|
||||
// 由外部图层面板触发,多选组件并保持主选中项。
|
||||
function selectLayersByIds(ids: string[], primaryId?: string | null) {
|
||||
if (!ids.length) {
|
||||
clearSelection()
|
||||
return false
|
||||
}
|
||||
const existingIds = [...new Set(ids)].filter(id => layerList.value.some(item => item.id === id))
|
||||
if (!existingIds.length) {
|
||||
clearSelection()
|
||||
return false
|
||||
}
|
||||
setSelection(existingIds, primaryId ?? existingIds[0] ?? null)
|
||||
return true
|
||||
}
|
||||
|
||||
function selectGroupById(groupId: string, primaryId?: string | null) {
|
||||
const group = getLayerGroupById(layerList.value, groupId)
|
||||
if (!group) {
|
||||
clearSelection()
|
||||
return false
|
||||
}
|
||||
|
||||
setSelection(group.layerIds, primaryId ?? group.layerIds[0] ?? null, { groupId })
|
||||
return true
|
||||
}
|
||||
|
||||
async function removeSelectedLayers() {
|
||||
if (!selectedLayerIds.value.length) {
|
||||
return
|
||||
}
|
||||
// 批量删除(3个及以上)时弹出二次确认
|
||||
if (selectedLayerIds.value.length >= 3) {
|
||||
try {
|
||||
const { ElMessageBox } = await import('element-plus')
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除选中的 ${selectedLayerIds.value.length} 个图层吗?此操作不可撤销。`,
|
||||
'批量删除',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
layerStore.removeLayersByIds(selectedLayerIds.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
function getSelectedLayers() {
|
||||
const fallbackIds = selectedLayerIds.value.length ? selectedLayerIds.value : selectedLayerId.value ? [selectedLayerId.value] : []
|
||||
const selectedSet = new Set(fallbackIds)
|
||||
return layerList.value.filter(item => selectedSet.has(item.id))
|
||||
}
|
||||
|
||||
function hasLockedSelectedLayers() {
|
||||
return getSelectedLayers().some(layer => layer.config?.locked === true)
|
||||
}
|
||||
|
||||
function getSelectedOrderItems(): LayerOrderItem[] {
|
||||
if (selectedGroup.value?.groupId) {
|
||||
// 图层面板里选中分组时,排序相关操作需要把它看成单一条目。
|
||||
return [{
|
||||
kind: 'group',
|
||||
id: `group:${selectedGroup.value.groupId}`,
|
||||
groupId: selectedGroup.value.groupId,
|
||||
} satisfies LayerOrderItem]
|
||||
}
|
||||
|
||||
const fallbackIds = selectedLayerIds.value.length ? selectedLayerIds.value : selectedLayerId.value ? [selectedLayerId.value] : []
|
||||
if (!fallbackIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items: LayerOrderItem[] = []
|
||||
fallbackIds.forEach((id) => {
|
||||
const layer = layerList.value.find(item => item.id === id)
|
||||
if (!layer) {
|
||||
return
|
||||
}
|
||||
items.push({
|
||||
kind: 'component',
|
||||
id,
|
||||
groupId: getLayerGroupMeta(layer)?.groupId ?? undefined,
|
||||
})
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
function bringSelectedToFront() {
|
||||
if (hasLockedSelectedLayers()) {
|
||||
return
|
||||
}
|
||||
const orderItems = getSelectedOrderItems()
|
||||
if (!orderItems.length) {
|
||||
return
|
||||
}
|
||||
layerActions.moveLayerItemsToEdge(orderItems, 'front')
|
||||
}
|
||||
|
||||
function sendSelectedToBack() {
|
||||
if (hasLockedSelectedLayers()) {
|
||||
return
|
||||
}
|
||||
const orderItems = getSelectedOrderItems()
|
||||
if (!orderItems.length) {
|
||||
return
|
||||
}
|
||||
layerActions.moveLayerItemsToEdge(orderItems, 'back')
|
||||
}
|
||||
|
||||
function moveSelectedForward() {
|
||||
if (hasLockedSelectedLayers()) {
|
||||
return
|
||||
}
|
||||
const orderItems = getSelectedOrderItems()
|
||||
if (!orderItems.length) {
|
||||
return
|
||||
}
|
||||
layerActions.moveLayerItemsByStep(orderItems, 'forward')
|
||||
}
|
||||
|
||||
function moveSelectedBackward() {
|
||||
if (hasLockedSelectedLayers()) {
|
||||
return
|
||||
}
|
||||
const orderItems = getSelectedOrderItems()
|
||||
if (!orderItems.length) {
|
||||
return
|
||||
}
|
||||
layerActions.moveLayerItemsByStep(orderItems, 'backward')
|
||||
}
|
||||
|
||||
function canBringSelectedToFront() {
|
||||
if (hasLockedSelectedLayers()) {
|
||||
return false
|
||||
}
|
||||
const orderItems = getSelectedOrderItems()
|
||||
return orderItems.length > 0 && layerActions.canMoveLayerItemsToEdge(orderItems, 'front')
|
||||
}
|
||||
|
||||
function canSendSelectedToBack() {
|
||||
if (hasLockedSelectedLayers()) {
|
||||
return false
|
||||
}
|
||||
const orderItems = getSelectedOrderItems()
|
||||
return orderItems.length > 0 && layerActions.canMoveLayerItemsToEdge(orderItems, 'back')
|
||||
}
|
||||
|
||||
function canMoveSelectedForward() {
|
||||
if (hasLockedSelectedLayers()) {
|
||||
return false
|
||||
}
|
||||
const orderItems = getSelectedOrderItems()
|
||||
return orderItems.length > 0 && layerActions.canMoveLayerItemsByStep(orderItems, 'forward')
|
||||
}
|
||||
|
||||
function canMoveSelectedBackward() {
|
||||
if (hasLockedSelectedLayers()) {
|
||||
return false
|
||||
}
|
||||
const orderItems = getSelectedOrderItems()
|
||||
return orderItems.length > 0 && layerActions.canMoveLayerItemsByStep(orderItems, 'backward')
|
||||
}
|
||||
|
||||
function groupSelected(groupName?: string) {
|
||||
if (!layerActions.canGroupLayers(selectedLayerIds.value)) {
|
||||
return false
|
||||
}
|
||||
const groupId = layerActions.groupLayers(selectedLayerIds.value, groupName)
|
||||
if (!groupId) {
|
||||
return false
|
||||
}
|
||||
selectGroupById(groupId)
|
||||
return true
|
||||
}
|
||||
|
||||
async function ungroupSelected() {
|
||||
const nextIds = selectedGroup.value?.layerIds ?? [...selectedLayerIds.value]
|
||||
// 组内图层较多时弹出二次确认
|
||||
if (nextIds.length >= 5) {
|
||||
try {
|
||||
const { ElMessageBox } = await import('element-plus')
|
||||
await ElMessageBox.confirm(
|
||||
`当前分组包含 ${nextIds.length} 个图层,确定解组吗?`,
|
||||
'解组确认',
|
||||
{ confirmButtonText: '解组', cancelButtonText: '取消', type: 'info' },
|
||||
)
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const ungrouped = layerActions.ungroupLayers(nextIds)
|
||||
if (!ungrouped) {
|
||||
return false
|
||||
}
|
||||
setSelection(nextIds, nextIds[0] ?? null, { groupId: null })
|
||||
return true
|
||||
}
|
||||
|
||||
function replaceLayers(nextLayers: Layer[]) {
|
||||
layerStore.replaceLayers(nextLayers)
|
||||
}
|
||||
|
||||
function getDefaultItemSize(type = 'custom') {
|
||||
const sizeMap: Record<string, { width: number, height: number }> = {
|
||||
rect: { width: 160, height: 100 },
|
||||
number: { width: 180, height: 72 },
|
||||
text: { width: 180, height: 64 },
|
||||
bar: { width: 48, height: 120 },
|
||||
button: { width: 120, height: 40 },
|
||||
pidController: { width: 160, height: 140 },
|
||||
valveController: { width: 100, height: 60 },
|
||||
canvasSwitcher: { width: 300, height: 40 },
|
||||
}
|
||||
const preset = sizeMap[type] || { width: 140, height: 60 }
|
||||
const baseWidth = preset.width
|
||||
const baseHeight = preset.height
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return { width: baseWidth, height: baseHeight }
|
||||
}
|
||||
return {
|
||||
width: Math.min(baseWidth, canvasView.value.imageWidth),
|
||||
height: Math.min(baseHeight, canvasView.value.imageHeight),
|
||||
}
|
||||
}
|
||||
|
||||
function placeLayerAtClientPoint(pointer: PointerPosition, type = 'custom') {
|
||||
if (!pointer || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return false
|
||||
}
|
||||
if (pointer.canvasX < 0 || pointer.canvasY < 0 || pointer.canvasX > pointer.rect.width || pointer.canvasY > pointer.rect.height) {
|
||||
return false
|
||||
}
|
||||
|
||||
const size = getDefaultItemSize(type)
|
||||
const imageX = pointer.imageX
|
||||
const imageY = pointer.imageY
|
||||
|
||||
const clampedX = clamp(imageX, 0, Math.max(0, canvasView.value.imageWidth - size.width))
|
||||
const clampedY = clamp(imageY, 0, Math.max(0, canvasView.value.imageHeight - size.height))
|
||||
|
||||
const snappedX = clamp(snapToGrid(clampedX, GRID_SIZE), 0, Math.max(0, canvasView.value.imageWidth - size.width))
|
||||
const snappedY = clamp(snapToGrid(clampedY, GRID_SIZE), 0, Math.max(0, canvasView.value.imageHeight - size.height))
|
||||
|
||||
// 新增组件默认即为可见、可编辑状态,便于拖入后马上调整。
|
||||
const newLayer: Layer = {
|
||||
id: createLayerId(),
|
||||
type: type as any,
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
config: {
|
||||
locked: false,
|
||||
visible: true,
|
||||
fillColor: type === 'button' ? '#C8C8C8' : type === 'rect' ? '#B7B7B7' : undefined,
|
||||
radius: type === 'rect' ? 0 : undefined,
|
||||
fontSize: type === 'number' ? 24 : type === 'text' ? 18 : undefined,
|
||||
fontWeight: type === 'number' ? 700 : 500,
|
||||
content: type === 'text' ? '文本' : undefined,
|
||||
decimals: type === 'number' ? 2 : undefined,
|
||||
...(type === 'bar'
|
||||
? {
|
||||
value: 50,
|
||||
direction: 'vertical',
|
||||
min: 0,
|
||||
max: 100,
|
||||
showValue: true,
|
||||
fillColor: '#000000',
|
||||
foregroundColor: '#00ff00',
|
||||
}
|
||||
: {}),
|
||||
...(type === 'button'
|
||||
? {
|
||||
label: '按钮',
|
||||
buttonType: 'trigger',
|
||||
confirmRequired: false,
|
||||
}
|
||||
: {}),
|
||||
...(type === 'pidController'
|
||||
? {
|
||||
headerColor: '#0055ff',
|
||||
labelColor: '#00cc00',
|
||||
valueColor: '#ffffff',
|
||||
bgColor: '#1a1a2e',
|
||||
}
|
||||
: {}),
|
||||
...(type === 'valveController'
|
||||
? {
|
||||
bgColor: '#1a1a2e',
|
||||
textColor: '#ffffff',
|
||||
}
|
||||
: {}),
|
||||
...(type === 'canvasSwitcher'
|
||||
? {
|
||||
items: [],
|
||||
activeColor: '#1677ff',
|
||||
inactiveColor: '#f0f0f0',
|
||||
activeTextColor: '#ffffff',
|
||||
inactiveTextColor: '#333333',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
|
||||
layerStore.appendLayers([newLayer])
|
||||
setSelection([newLayer.id], newLayer.id)
|
||||
return true
|
||||
}
|
||||
|
||||
function placeTemplateAtPoint(pointer: PointerPosition, templateLayers: Layer[]) {
|
||||
if (!pointer || !templateLayers.length || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return false
|
||||
}
|
||||
if (pointer.canvasX < 0 || pointer.canvasY < 0 || pointer.canvasX > pointer.rect.width || pointer.canvasY > pointer.rect.height) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 计算模板图层的包围盒
|
||||
const minX = Math.min(...templateLayers.map(l => l.x))
|
||||
const minY = Math.min(...templateLayers.map(l => l.y))
|
||||
|
||||
// 将模板放到拖放位置,保持相对位置
|
||||
const offsetX = pointer.imageX - minX
|
||||
const offsetY = pointer.imageY - minY
|
||||
|
||||
const newLayers = templateLayers.map(l => ({
|
||||
...JSON.parse(JSON.stringify(l)),
|
||||
id: createLayerId(),
|
||||
x: clamp(l.x + offsetX, 0, canvasView.value.imageWidth - l.width),
|
||||
y: clamp(l.y + offsetY, 0, canvasView.value.imageHeight - l.height),
|
||||
}))
|
||||
|
||||
layerStore.appendLayers(newLayers)
|
||||
setSelection(newLayers.map(l => l.id), newLayers[0].id)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
setSelection,
|
||||
clearSelection,
|
||||
selectLayerById,
|
||||
selectLayersByIds,
|
||||
selectGroupById,
|
||||
removeSelectedLayers,
|
||||
getSelectedLayers,
|
||||
bringSelectedToFront,
|
||||
sendSelectedToBack,
|
||||
moveSelectedForward,
|
||||
moveSelectedBackward,
|
||||
canBringSelectedToFront,
|
||||
canSendSelectedToBack,
|
||||
canMoveSelectedForward,
|
||||
canMoveSelectedBackward,
|
||||
canGroupSelected: () => layerActions.canGroupLayers(selectedLayerIds.value),
|
||||
groupSelected,
|
||||
ungroupSelected,
|
||||
replaceLayers,
|
||||
placeLayerAtClientPoint,
|
||||
placeTemplateAtPoint,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { deleteCanvasApi } from '@/api'
|
||||
import pinia, { useCanvasStore } from '@/stores'
|
||||
|
||||
type DeletePageResult
|
||||
= | { ok: true }
|
||||
| { ok: false, reason: 'not_found' | 'last_page' }
|
||||
|
||||
export function useCanvasPageActions() {
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
const { canvasList, canvasId } = storeToRefs(canvasStore)
|
||||
|
||||
async function deletePageById(pageId: string): Promise<DeletePageResult> {
|
||||
const exists = canvasList.value.some(page => page.id === pageId)
|
||||
if (!exists) {
|
||||
return { ok: false, reason: 'not_found' }
|
||||
}
|
||||
|
||||
if (canvasList.value.length <= 1) {
|
||||
return { ok: false, reason: 'last_page' }
|
||||
}
|
||||
|
||||
await deleteCanvasApi(pageId)
|
||||
const nextList = await canvasStore.getCanvasList()
|
||||
|
||||
if (canvasId.value === pageId) {
|
||||
// 当前页被删掉时自动切到新列表中的第一页,避免编辑器落在空状态。
|
||||
const [nextCanvas] = nextList
|
||||
canvasStore.setCanvasId(nextCanvas?.id || null)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
return {
|
||||
deletePageById,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { updateCanvasBaseLayer, updateComponentsLayer } from '@/api'
|
||||
import { useCanvasState } from '../context/state'
|
||||
|
||||
// Module-level shared state for save status
|
||||
const saveStatus = ref<'saved' | 'saving' | 'idle' | 'error'>('idle')
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_BASE_DELAY = 500
|
||||
|
||||
async function withRetry<T>(fn: () => Promise<T>, retries = MAX_RETRIES): Promise<T> {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
}
|
||||
catch (error) {
|
||||
if (attempt === retries)
|
||||
throw error
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_BASE_DELAY * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
|
||||
/** External components (e.g., StatusBar) can read save status via this */
|
||||
export function useSaveStatus() {
|
||||
return { saveStatus }
|
||||
}
|
||||
|
||||
async function notifySaveError(context: string, error: unknown) {
|
||||
try {
|
||||
const { ElNotification } = await import('element-plus')
|
||||
ElNotification.error({
|
||||
title: '保存失败',
|
||||
message: `${context}保存失败,请检查存储空间或刷新页面重试。`,
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// 静默:通知组件不可用时不影响主流程
|
||||
}
|
||||
console.error(`[canvas] ${context} 保存失败`, error)
|
||||
}
|
||||
|
||||
// 组件图层数据持久化相关逻辑
|
||||
export function useLayerPersistence() {
|
||||
const { canvasId, layerList, isHydratingCanvas, canvasView, activeCanvasThumbnail } = useCanvasState()
|
||||
|
||||
async function persistComponentsLayerNow() {
|
||||
// 画布恢复阶段跳过保存,避免把服务端数据刚拉下来又原样写回。
|
||||
if (!canvasId.value || isHydratingCanvas.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = canvasId.value
|
||||
saveStatus.value = 'saving'
|
||||
try {
|
||||
await withRetry(() => updateComponentsLayer({
|
||||
id,
|
||||
components: layerList.value,
|
||||
}))
|
||||
saveStatus.value = 'saved'
|
||||
}
|
||||
catch (error) {
|
||||
saveStatus.value = 'error'
|
||||
notifySaveError('组件图层', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件变更统一走防抖保存,减少拖拽/缩放期间的高频请求。
|
||||
const persistComponentsLayer = useDebounceFn(persistComponentsLayerNow, 300)
|
||||
|
||||
// 底图属性(宽高/宽高比锁定)自动保存
|
||||
async function persistCanvasBaseLayerNow() {
|
||||
if (!canvasId.value || isHydratingCanvas.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = canvasId.value
|
||||
saveStatus.value = 'saving'
|
||||
try {
|
||||
await withRetry(() => updateCanvasBaseLayer({
|
||||
id,
|
||||
width: Math.round(canvasView.value.imageWidth),
|
||||
height: Math.round(canvasView.value.imageHeight),
|
||||
thumbnail: activeCanvasThumbnail.value ? activeCanvasThumbnail.value : null,
|
||||
lockAspectRatio: canvasView.value.backgroundLockAspectRatio,
|
||||
backgroundColor: canvasView.value.background,
|
||||
}))
|
||||
saveStatus.value = 'saved'
|
||||
}
|
||||
catch (error) {
|
||||
saveStatus.value = 'error'
|
||||
notifySaveError('底图属性', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 底图属性也走独立的防抖保存,避免和组件数据互相影响。
|
||||
const persistCanvasBaseLayer = useDebounceFn(persistCanvasBaseLayerNow, 300)
|
||||
|
||||
return {
|
||||
saveStatus,
|
||||
persistComponentsLayerNow,
|
||||
persistComponentsLayer,
|
||||
persistCanvasBaseLayer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type { CanvasView, Layer } from '../types'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
export interface BaseLayerSnapshot {
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
background: string
|
||||
backgroundLockAspectRatio: boolean
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
layerList: Layer[]
|
||||
baseLayer: BaseLayerSnapshot
|
||||
timestamp: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface UseHistoryReturn {
|
||||
canUndo: ComputedRef<boolean>
|
||||
canRedo: ComputedRef<boolean>
|
||||
undoLabel: ComputedRef<string>
|
||||
redoLabel: ComputedRef<string>
|
||||
pushState: (label: string, mergeContext?: { layerIds?: string[], fieldPath?: string }) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
clear: () => void
|
||||
isRestoring: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 50
|
||||
const MERGE_WINDOW_MS = 300
|
||||
|
||||
interface MergeKey {
|
||||
label: string
|
||||
layerIds?: string[]
|
||||
fieldPath?: string
|
||||
}
|
||||
|
||||
function isSameMergeKey(a: MergeKey, b: MergeKey): boolean {
|
||||
if (a.label !== b.label)
|
||||
return false
|
||||
if (a.fieldPath !== b.fieldPath)
|
||||
return false
|
||||
const aIds = a.layerIds?.join(',') ?? ''
|
||||
const bIds = b.layerIds?.join(',') ?? ''
|
||||
return aIds === bIds
|
||||
}
|
||||
|
||||
export function useHistory(
|
||||
getLayerList: () => Layer[],
|
||||
getCanvasView: () => CanvasView,
|
||||
onRestore: (entry: HistoryEntry) => void,
|
||||
): UseHistoryReturn {
|
||||
const undoStack = shallowRef<HistoryEntry[]>([])
|
||||
const redoStack = shallowRef<HistoryEntry[]>([])
|
||||
const _isRestoring = ref(false)
|
||||
const lastMergeKey = ref<MergeKey>({ label: '' })
|
||||
const lastPushTime = ref(0)
|
||||
|
||||
function captureSnapshot(label: string): HistoryEntry {
|
||||
const view = getCanvasView()
|
||||
return {
|
||||
layerList: structuredClone(getLayerList()),
|
||||
baseLayer: {
|
||||
imageWidth: view.imageWidth,
|
||||
imageHeight: view.imageHeight,
|
||||
background: view.background,
|
||||
backgroundLockAspectRatio: view.backgroundLockAspectRatio,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
function pushState(label: string, mergeContext?: { layerIds?: string[], fieldPath?: string }) {
|
||||
if (_isRestoring.value)
|
||||
return
|
||||
const now = Date.now()
|
||||
const currentKey: MergeKey = { label, layerIds: mergeContext?.layerIds, fieldPath: mergeContext?.fieldPath }
|
||||
if (
|
||||
isSameMergeKey(lastMergeKey.value, currentKey)
|
||||
&& now - lastPushTime.value < MERGE_WINDOW_MS
|
||||
&& undoStack.value.length > 0
|
||||
) {
|
||||
lastPushTime.value = now
|
||||
return
|
||||
}
|
||||
const entry = captureSnapshot(label)
|
||||
const next = [...undoStack.value, entry]
|
||||
if (next.length > MAX_HISTORY) {
|
||||
next.splice(0, next.length - MAX_HISTORY)
|
||||
}
|
||||
undoStack.value = next
|
||||
redoStack.value = []
|
||||
lastMergeKey.value = currentKey
|
||||
lastPushTime.value = now
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (undoStack.value.length <= 1)
|
||||
return
|
||||
_isRestoring.value = true
|
||||
try {
|
||||
const stack = [...undoStack.value]
|
||||
const current = stack.pop()!
|
||||
redoStack.value = [...redoStack.value, current]
|
||||
undoStack.value = stack
|
||||
const target = stack.at(-1)
|
||||
if (target) {
|
||||
onRestore(target)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
_isRestoring.value = false
|
||||
}
|
||||
lastMergeKey.value = { label: '' }
|
||||
lastPushTime.value = 0
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (redoStack.value.length === 0)
|
||||
return
|
||||
_isRestoring.value = true
|
||||
try {
|
||||
const stack = [...redoStack.value]
|
||||
const target = stack.pop()!
|
||||
redoStack.value = stack
|
||||
undoStack.value = [...undoStack.value, target]
|
||||
onRestore(target)
|
||||
}
|
||||
finally {
|
||||
_isRestoring.value = false
|
||||
}
|
||||
lastMergeKey.value = { label: '' }
|
||||
lastPushTime.value = 0
|
||||
}
|
||||
|
||||
function clear() {
|
||||
undoStack.value = []
|
||||
redoStack.value = []
|
||||
lastMergeKey.value = { label: '' }
|
||||
lastPushTime.value = 0
|
||||
}
|
||||
|
||||
const canUndo = computed(() => undoStack.value.length > 1)
|
||||
const canRedo = computed(() => redoStack.value.length > 0)
|
||||
const undoLabel = computed(() => {
|
||||
if (undoStack.value.length <= 1)
|
||||
return ''
|
||||
return undoStack.value.at(-1)?.label || ''
|
||||
})
|
||||
const redoLabel = computed(() => {
|
||||
if (redoStack.value.length === 0)
|
||||
return ''
|
||||
return redoStack.value.at(-1)?.label || ''
|
||||
})
|
||||
const isRestoring = computed(() => _isRestoring.value)
|
||||
|
||||
return { canUndo, canRedo, undoLabel, redoLabel, pushState, undo, redo, clear, isRestoring }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { LayerPositionUpdate } from './useLayerMutations'
|
||||
import { useCanvasState } from '../context/state'
|
||||
import { clamp, snapToGrid } from '../utils'
|
||||
|
||||
const GRID_SIZE = 10
|
||||
|
||||
type AlignType = 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom'
|
||||
|
||||
export function useLayerAlignment(deps: {
|
||||
updateLayerPosition: (payload: LayerPositionUpdate) => void
|
||||
updateLayerPositions: (updates: LayerPositionUpdate[]) => void
|
||||
}) {
|
||||
const { canvasView, layerList, selectedLayerId, selectedLayerIds } = useCanvasState()
|
||||
|
||||
function getSelectedLayers() {
|
||||
const fallbackIds = selectedLayerIds.value.length ? selectedLayerIds.value : selectedLayerId.value ? [selectedLayerId.value] : []
|
||||
const selectedSet = new Set(fallbackIds)
|
||||
return layerList.value.filter(layer => selectedSet.has(layer.id))
|
||||
}
|
||||
|
||||
function alignSelected(type: AlignType) {
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
const layers = getSelectedLayers()
|
||||
if (!layers.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (layers.length === 1) {
|
||||
// 单个图层时参考对象是整个底图,多图层时参考对象是选区包围盒。
|
||||
const layer = layers[0]
|
||||
let newX = layer.x
|
||||
let newY = layer.y
|
||||
|
||||
switch (type) {
|
||||
case 'left':
|
||||
newX = 0
|
||||
break
|
||||
case 'center':
|
||||
newX = (canvasView.value.imageWidth - layer.width) / 2
|
||||
break
|
||||
case 'right':
|
||||
newX = canvasView.value.imageWidth - layer.width
|
||||
break
|
||||
case 'top':
|
||||
newY = 0
|
||||
break
|
||||
case 'middle':
|
||||
newY = (canvasView.value.imageHeight - layer.height) / 2
|
||||
break
|
||||
case 'bottom':
|
||||
newY = canvasView.value.imageHeight - layer.height
|
||||
break
|
||||
}
|
||||
|
||||
deps.updateLayerPosition({
|
||||
id: layer.id,
|
||||
nextX: clamp(snapToGrid(newX, GRID_SIZE), 0, Math.max(0, canvasView.value.imageWidth - layer.width)),
|
||||
nextY: clamp(snapToGrid(newY, GRID_SIZE), 0, Math.max(0, canvasView.value.imageHeight - layer.height)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const left = Math.min(...layers.map(layer => layer.x))
|
||||
const top = Math.min(...layers.map(layer => layer.y))
|
||||
const right = Math.max(...layers.map(layer => layer.x + layer.width))
|
||||
const bottom = Math.max(...layers.map(layer => layer.y + layer.height))
|
||||
const centerX = (left + right) / 2
|
||||
const centerY = (top + bottom) / 2
|
||||
|
||||
const updates: LayerPositionUpdate[] = layers.map((layer) => {
|
||||
let nextX = layer.x
|
||||
let nextY = layer.y
|
||||
|
||||
switch (type) {
|
||||
case 'left':
|
||||
nextX = left
|
||||
break
|
||||
case 'center':
|
||||
nextX = centerX - layer.width / 2
|
||||
break
|
||||
case 'right':
|
||||
nextX = right - layer.width
|
||||
break
|
||||
case 'top':
|
||||
nextY = top
|
||||
break
|
||||
case 'middle':
|
||||
nextY = centerY - layer.height / 2
|
||||
break
|
||||
case 'bottom':
|
||||
nextY = bottom - layer.height
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
id: layer.id,
|
||||
nextX: clamp(snapToGrid(nextX, GRID_SIZE), 0, Math.max(0, canvasView.value.imageWidth - layer.width)),
|
||||
nextY: clamp(snapToGrid(nextY, GRID_SIZE), 0, Math.max(0, canvasView.value.imageHeight - layer.height)),
|
||||
}
|
||||
})
|
||||
|
||||
deps.updateLayerPositions(updates)
|
||||
}
|
||||
|
||||
return {
|
||||
alignSelected,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import { useComponentTemplates } from '@/composables/useComponentTemplates'
|
||||
import { useOperationLog } from '@/composables/useOperationLog'
|
||||
import { useCanvasInject } from '../context'
|
||||
import { useCanvasState } from '../context/state'
|
||||
import { listenEditorEmitter, setEditorEmitter } from '../emitter'
|
||||
import { useLayerActions, useLayerSelection } from './layer'
|
||||
import { useLayerPersistence } from './persistence'
|
||||
import { useHistory } from './useHistory'
|
||||
import { useLayerAlignment } from './useLayerAlignment'
|
||||
import { useLayerMutations } from './useLayerMutations'
|
||||
import { useLayerSelectionSync } from './useLayerSelectionSync'
|
||||
import { useSelectionBox } from './useSelectionBox'
|
||||
|
||||
export function useLayerController() {
|
||||
const { layerList, selectedLayerId, selectedLayerIds, isHydratingCanvas, canvasView } = useCanvasState()
|
||||
const { log: logOperation } = useOperationLog()
|
||||
const {
|
||||
clearSelection,
|
||||
setSelection,
|
||||
selectLayerById,
|
||||
selectLayersByIds,
|
||||
bringSelectedToFront: _bringSelectedToFront,
|
||||
sendSelectedToBack: _sendSelectedToBack,
|
||||
groupSelected: _groupSelected,
|
||||
ungroupSelected: _ungroupSelected,
|
||||
placeLayerAtClientPoint,
|
||||
placeTemplateAtPoint,
|
||||
replaceLayers,
|
||||
} = useLayerSelection()
|
||||
const { toggleLayerLock, toggleLayerVisible } = useLayerActions()
|
||||
const {
|
||||
updateLayerPosition,
|
||||
updateLayerPositions,
|
||||
updateLayerRect,
|
||||
updateLayerRects,
|
||||
updateLayerField,
|
||||
updateLayerFields,
|
||||
} = useLayerMutations()
|
||||
const { onBackgroundPointerDown } = useSelectionBox()
|
||||
const { alignSelected } = useLayerAlignment({
|
||||
updateLayerPosition,
|
||||
updateLayerPositions,
|
||||
})
|
||||
|
||||
useLayerSelectionSync()
|
||||
|
||||
const selectedCount = computed(() => selectedLayerIds.value.length)
|
||||
|
||||
const { persistComponentsLayer, persistCanvasBaseLayer } = useLayerPersistence()
|
||||
watch(
|
||||
layerList,
|
||||
() => {
|
||||
// 组件拖拽、缩放、删除等变更后自动落盘。
|
||||
persistComponentsLayer()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const history = useHistory(
|
||||
() => layerList.value,
|
||||
() => canvasView.value,
|
||||
(entry) => {
|
||||
// 恢复图层
|
||||
replaceLayers(entry.layerList)
|
||||
// 恢复底图属性
|
||||
const cv = canvasView.value
|
||||
cv.imageWidth = entry.baseLayer.imageWidth
|
||||
cv.imageHeight = entry.baseLayer.imageHeight
|
||||
cv.background = entry.baseLayer.background
|
||||
cv.backgroundLockAspectRatio = entry.baseLayer.backgroundLockAspectRatio
|
||||
persistCanvasBaseLayer()
|
||||
},
|
||||
)
|
||||
|
||||
const { getPointerImagePosition } = useCanvasInject()
|
||||
const { getTemplate } = useComponentTemplates()
|
||||
|
||||
function onCanvasDrop({ event, type }: { event: DragEvent, type: string }) {
|
||||
// 模板拖入
|
||||
if (type === '__template__') {
|
||||
let templateId = ''
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer?.getData('application/json') ?? '{}')
|
||||
templateId = data.templateId ?? ''
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
const template = templateId ? getTemplate(templateId) : null
|
||||
if (template) {
|
||||
const pointer = getPointerImagePosition(event.clientX, event.clientY)
|
||||
const placed = placeTemplateAtPoint(pointer, template.layers)
|
||||
if (placed) {
|
||||
history.pushState('从模板添加')
|
||||
logOperation('create', template.name, '从模板添加')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = getPointerImagePosition(event.clientX, event.clientY)
|
||||
const placed = placeLayerAtClientPoint(pointer, type)
|
||||
if (placed) {
|
||||
history.pushState('添加图层')
|
||||
logOperation('create', type, '添加图层')
|
||||
}
|
||||
}
|
||||
listenEditorEmitter('canvasStage:drop', onCanvasDrop)
|
||||
|
||||
function onLayerClick(payload: { event: MouseEvent, id: string }) {
|
||||
const { event, id } = payload
|
||||
const additive = event.metaKey || event.ctrlKey || event.shiftKey
|
||||
|
||||
if (!additive) {
|
||||
setSelection([id], id, { groupId: null })
|
||||
return
|
||||
}
|
||||
|
||||
const exists = selectedLayerIds.value.includes(id)
|
||||
if (exists) {
|
||||
// 加选模式下再次点击已选图层,等价于把它从当前选区移除。
|
||||
const next = selectedLayerIds.value.filter(entry => entry !== id)
|
||||
setSelection(next, selectedLayerId.value === id ? next[0] ?? null : selectedLayerId.value)
|
||||
return
|
||||
}
|
||||
|
||||
setSelection([...selectedLayerIds.value, id], id)
|
||||
}
|
||||
|
||||
function onCanvasLayerClick(payload: { event: MouseEvent, id: string }) {
|
||||
onLayerClick(payload)
|
||||
}
|
||||
listenEditorEmitter('canvasStage:layerClick', onCanvasLayerClick)
|
||||
|
||||
function toggleLayerLockState(id: string, locked?: boolean) {
|
||||
toggleLayerLock(id, locked)
|
||||
}
|
||||
|
||||
function toggleLayerVisibleState(id: string, visible?: boolean) {
|
||||
toggleLayerVisible(id, visible)
|
||||
}
|
||||
|
||||
watch(isHydratingCanvas, (hydrating, prevHydrating) => {
|
||||
if (prevHydrating && !hydrating) {
|
||||
history.pushState('初始状态')
|
||||
}
|
||||
})
|
||||
|
||||
// 监听来自 header 按钮和键盘快捷键的撤销/重做事件
|
||||
listenEditorEmitter('header:undo', () => {
|
||||
history.undo()
|
||||
logOperation('undo', '', '撤销操作')
|
||||
})
|
||||
listenEditorEmitter('header:redo', () => {
|
||||
history.redo()
|
||||
logOperation('redo', '', '重做操作')
|
||||
})
|
||||
listenEditorEmitter('history:push', ({ label }) => {
|
||||
history.pushState(label)
|
||||
logOperation('edit', '', label)
|
||||
})
|
||||
|
||||
// 向 header 广播历史状态变化
|
||||
watch([history.canUndo, history.canRedo, history.undoLabel, history.redoLabel], () => {
|
||||
setEditorEmitter('history:stateChange', {
|
||||
canUndo: history.canUndo.value,
|
||||
canRedo: history.canRedo.value,
|
||||
undoLabel: history.undoLabel.value,
|
||||
redoLabel: history.redoLabel.value,
|
||||
})
|
||||
})
|
||||
|
||||
// 包装图层操作,在操作成功后自动记录历史
|
||||
function bringSelectedToFront() {
|
||||
_bringSelectedToFront()
|
||||
history.pushState('调整层级')
|
||||
logOperation('edit', selectedLayerIds.value.join(','), '前置图层')
|
||||
}
|
||||
function sendSelectedToBack() {
|
||||
_sendSelectedToBack()
|
||||
history.pushState('调整层级')
|
||||
logOperation('edit', selectedLayerIds.value.join(','), '后置图层')
|
||||
}
|
||||
function groupSelected(groupName?: string) {
|
||||
const result = _groupSelected(groupName)
|
||||
if (result) {
|
||||
history.pushState('创建分组')
|
||||
logOperation('group', groupName ?? '', '创建分组')
|
||||
}
|
||||
return result
|
||||
}
|
||||
async function ungroupSelected() {
|
||||
const result = await _ungroupSelected()
|
||||
if (result) {
|
||||
history.pushState('解除分组')
|
||||
logOperation('group', '', '解除分组')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
layerList,
|
||||
selectedLayerIds,
|
||||
selectedLayerId,
|
||||
selectedCount,
|
||||
onCanvasDrop,
|
||||
onBackgroundPointerDown,
|
||||
onLayerClick,
|
||||
selectLayerById,
|
||||
selectLayersByIds,
|
||||
clearSelection,
|
||||
updateLayerPosition,
|
||||
updateLayerPositions,
|
||||
updateLayerRect,
|
||||
updateLayerRects,
|
||||
alignSelected,
|
||||
bringSelectedToFront,
|
||||
sendSelectedToBack,
|
||||
toggleLayerLockState,
|
||||
toggleLayerVisibleState,
|
||||
updateLayerField,
|
||||
updateLayerFields,
|
||||
groupSelected,
|
||||
ungroupSelected,
|
||||
history,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import pinia, { useLayerStore } from '@/stores'
|
||||
import { listenEditorEmitter } from '../emitter'
|
||||
|
||||
export interface LayerPositionUpdate {
|
||||
id: string
|
||||
nextX: number
|
||||
nextY: number
|
||||
}
|
||||
|
||||
export interface LayerRectUpdate extends LayerPositionUpdate {
|
||||
nextWidth: number
|
||||
nextHeight: number
|
||||
options?: { ignoreLock?: boolean }
|
||||
}
|
||||
|
||||
export function useLayerMutations() {
|
||||
const layerStore = useLayerStore(pinia)
|
||||
|
||||
function updateLayerPosition(payload: LayerPositionUpdate) {
|
||||
layerStore.updateLayerPosition(payload)
|
||||
}
|
||||
|
||||
function updateLayerPositions(updates: LayerPositionUpdate[]) {
|
||||
layerStore.updateLayerPositions(updates)
|
||||
}
|
||||
|
||||
function updateLayerRect(payload: LayerRectUpdate) {
|
||||
layerStore.updateLayerRect(payload)
|
||||
}
|
||||
|
||||
function updateLayerRects(updates: LayerRectUpdate[]) {
|
||||
layerStore.updateLayerRects(updates)
|
||||
}
|
||||
|
||||
function updateLayerField(id: string, field: string, value: unknown, options?: { ignoreLock?: boolean }) {
|
||||
layerStore.updateLayerField(id, field, value, options)
|
||||
}
|
||||
|
||||
function updateLayerFields(ids: string[], field: string, value: unknown, options?: { ignoreLock?: boolean }) {
|
||||
layerStore.updateLayerFields(ids, field, value, options)
|
||||
}
|
||||
|
||||
// 将舞台、图层面板、属性面板的更新请求统一桥接到 layer store。
|
||||
listenEditorEmitter('layer:updatePosition', updateLayerPosition)
|
||||
listenEditorEmitter('layer:updatePositions', updateLayerPositions)
|
||||
listenEditorEmitter('layer:updateRect', updateLayerRect)
|
||||
listenEditorEmitter('layer:updateRects', updateLayerRects)
|
||||
listenEditorEmitter('layer:updateField', ({ id, field, value, options }) => {
|
||||
updateLayerField(id, field, value, options)
|
||||
})
|
||||
listenEditorEmitter('layer:updateFields', ({ ids, field, value, options }) => {
|
||||
updateLayerFields(ids, field, value, options)
|
||||
})
|
||||
|
||||
return {
|
||||
updateLayerPosition,
|
||||
updateLayerPositions,
|
||||
updateLayerRect,
|
||||
updateLayerRects,
|
||||
updateLayerField,
|
||||
updateLayerFields,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { Layer } from '../types'
|
||||
import { useCanvasState } from '../context/state'
|
||||
|
||||
export function useLayerRender() {
|
||||
const { canvasView } = useCanvasState()
|
||||
|
||||
function getLayerStyle(layer: Layer): CSSProperties {
|
||||
// 图层 DOM 始终按当前视口偏移和缩放换算,和底图保持同一坐标系。
|
||||
const left = canvasView.value.offsetX + layer.x * canvasView.value.scale
|
||||
const top = canvasView.value.offsetY + layer.y * canvasView.value.scale
|
||||
const width = layer.width * canvasView.value.scale
|
||||
const height = layer.height * canvasView.value.scale
|
||||
const rotate = layer.config?.rotate ?? 0
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
opacity: layer.config?.visible === false ? 0.35 : 1,
|
||||
cursor: layer.config?.locked ? 'not-allowed' : 'pointer',
|
||||
...(rotate ? { transform: `rotate(${rotate}deg)`, transformOrigin: 'center center' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getLayerStyle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { watch } from 'vue'
|
||||
import pinia, { useLayerStore } from '@/stores'
|
||||
import { useCanvasState } from '../context/state'
|
||||
import { setEditorEmitter } from '../emitter'
|
||||
import { useLayerSelection } from './layer'
|
||||
|
||||
export function useLayerSelectionSync() {
|
||||
const { layerList, selectedLayerId, selectedLayerIds, selectedGroupId, isBaseLayerActive } = useCanvasState()
|
||||
const layerStore = useLayerStore(pinia)
|
||||
const { selectLayerById } = useLayerSelection()
|
||||
|
||||
watch(
|
||||
() => selectedLayerIds.value,
|
||||
(ids) => {
|
||||
// 多选数组是主要真值,主选中项和属性面板展示都从它推导。
|
||||
if (!ids.length) {
|
||||
layerStore.setLayerId('')
|
||||
if (isBaseLayerActive.value) {
|
||||
setEditorEmitter('propertyPanel:openPanel')
|
||||
}
|
||||
else {
|
||||
setEditorEmitter('propertyPanel:closePanel')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const primaryId = ids.includes(selectedLayerId.value) ? selectedLayerId.value : ids.at(-1)
|
||||
if (primaryId && primaryId !== selectedLayerId.value) {
|
||||
layerStore.setLayerId(primaryId)
|
||||
return
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
setEditorEmitter('propertyPanel:openPanel')
|
||||
return
|
||||
}
|
||||
if (ids.length === 1) {
|
||||
setEditorEmitter('propertyPanel:openPanel')
|
||||
return
|
||||
}
|
||||
setEditorEmitter('propertyPanel:closePanel')
|
||||
},
|
||||
{ deep: false, flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => selectedLayerId.value,
|
||||
(nextLayerId) => {
|
||||
// 外部如果只改了主选中项,这里会把多选数组补齐回一致状态。
|
||||
if (!nextLayerId) {
|
||||
return
|
||||
}
|
||||
if (selectedGroupId.value && selectedLayerIds.value.includes(nextLayerId)) {
|
||||
setEditorEmitter('propertyPanel:openPanel')
|
||||
return
|
||||
}
|
||||
if (selectedLayerIds.value.length > 1 && selectedLayerIds.value.includes(nextLayerId)) {
|
||||
setEditorEmitter('propertyPanel:closePanel')
|
||||
return
|
||||
}
|
||||
const selected = selectLayerById(nextLayerId)
|
||||
if (selected) {
|
||||
setEditorEmitter('propertyPanel:openPanel')
|
||||
return
|
||||
}
|
||||
layerStore.setLayerId('')
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => isBaseLayerActive.value,
|
||||
(active) => {
|
||||
if (selectedLayerIds.value.length) {
|
||||
return
|
||||
}
|
||||
if (active) {
|
||||
setEditorEmitter('propertyPanel:openPanel')
|
||||
}
|
||||
else {
|
||||
setEditorEmitter('propertyPanel:closePanel')
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => selectedGroupId.value,
|
||||
(groupId) => {
|
||||
if (groupId) {
|
||||
setEditorEmitter('propertyPanel:openPanel')
|
||||
return
|
||||
}
|
||||
if (selectedLayerIds.value.length > 1) {
|
||||
setEditorEmitter('propertyPanel:closePanel')
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(
|
||||
layerList,
|
||||
() => {
|
||||
// 图层被删除后兜底清空失效选中态,避免属性面板指向幽灵节点。
|
||||
if (!selectedLayerId.value) {
|
||||
return
|
||||
}
|
||||
const exists = layerList.value.some(item => item.id === selectedLayerId.value)
|
||||
if (!exists) {
|
||||
layerStore.setLayerId('')
|
||||
if (!selectedLayerIds.value.length) {
|
||||
if (isBaseLayerActive.value) {
|
||||
setEditorEmitter('propertyPanel:openPanel')
|
||||
}
|
||||
else {
|
||||
setEditorEmitter('propertyPanel:closePanel')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useCanvasInject } from '../context'
|
||||
import { useCanvasState } from '../context/state'
|
||||
import { listenEditorEmitter, setEditorEmitter } from '../emitter'
|
||||
import { buildLayerGroups } from '../grouping'
|
||||
import { normalizeRect, rectIntersects } from '../utils'
|
||||
import { useLayerSelection } from './layer'
|
||||
|
||||
const BOX_SELECT_THRESHOLD = 4
|
||||
|
||||
export function useSelectionBox() {
|
||||
const { canvasView, layerList, selectedLayerId, selectedLayerIds, isBaseLayerActive, boxSelect } = useCanvasState()
|
||||
const { clearSelection, selectGroupById, setSelection } = useLayerSelection()
|
||||
const { getPointerImagePosition } = useCanvasInject()
|
||||
const pointerTracker = {
|
||||
startClientX: 0,
|
||||
startClientY: 0,
|
||||
currentClientX: 0,
|
||||
currentClientY: 0,
|
||||
hitGroupId: '',
|
||||
panelSuspended: false,
|
||||
}
|
||||
|
||||
function isPointInsideRect(point: { imageX: number, imageY: number }, rect: { x: number, y: number, width: number, height: number }) {
|
||||
return point.imageX >= rect.x
|
||||
&& point.imageY >= rect.y
|
||||
&& point.imageX <= rect.x + rect.width
|
||||
&& point.imageY <= rect.y + rect.height
|
||||
}
|
||||
|
||||
function isPointMoved() {
|
||||
return Math.abs(pointerTracker.currentClientX - pointerTracker.startClientX) > BOX_SELECT_THRESHOLD
|
||||
|| Math.abs(pointerTracker.currentClientY - pointerTracker.startClientY) > BOX_SELECT_THRESHOLD
|
||||
}
|
||||
|
||||
function stopBoxSelect() {
|
||||
// 统一从这里收尾,确保事件和属性面板状态都能被正确恢复。
|
||||
boxSelect.value.active = false
|
||||
boxSelect.value.moved = false
|
||||
pointerTracker.hitGroupId = ''
|
||||
if (pointerTracker.panelSuspended) {
|
||||
pointerTracker.panelSuspended = false
|
||||
setEditorEmitter('propertyPanel:resume')
|
||||
}
|
||||
window.removeEventListener('pointermove', onBoxSelectMove)
|
||||
window.removeEventListener('pointerup', onBoxSelectUp)
|
||||
}
|
||||
|
||||
function onBoxSelectMove(event: PointerEvent) {
|
||||
if (!boxSelect.value.active) {
|
||||
return
|
||||
}
|
||||
const pointer = getPointerImagePosition(event.clientX, event.clientY)
|
||||
if (!pointer) {
|
||||
return
|
||||
}
|
||||
pointerTracker.currentClientX = event.clientX
|
||||
pointerTracker.currentClientY = event.clientY
|
||||
boxSelect.value.currentX = pointer.imageX
|
||||
boxSelect.value.currentY = pointer.imageY
|
||||
boxSelect.value.moved = isPointMoved()
|
||||
if (boxSelect.value.moved && !pointerTracker.panelSuspended) {
|
||||
pointerTracker.panelSuspended = true
|
||||
setEditorEmitter('propertyPanel:suspend')
|
||||
}
|
||||
}
|
||||
|
||||
function onBoxSelectUp() {
|
||||
if (!boxSelect.value.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!boxSelect.value.moved) {
|
||||
if (pointerTracker.hitGroupId) {
|
||||
selectGroupById(pointerTracker.hitGroupId)
|
||||
stopBoxSelect()
|
||||
return
|
||||
}
|
||||
|
||||
if (!boxSelect.value.additive && !isBaseLayerActive.value) {
|
||||
clearSelection()
|
||||
}
|
||||
stopBoxSelect()
|
||||
return
|
||||
}
|
||||
|
||||
const selectionRect = normalizeRect(boxSelect.value.startX, boxSelect.value.startY, boxSelect.value.currentX, boxSelect.value.currentY)
|
||||
// 框选采用“矩形相交”策略,而不是完全包裹,降低选中门槛。
|
||||
const nextIds = layerList.value
|
||||
.filter(item => rectIntersects(selectionRect, item))
|
||||
.map(item => item.id)
|
||||
|
||||
if (boxSelect.value.additive) {
|
||||
setSelection([...selectedLayerIds.value, ...nextIds], nextIds[0] ?? selectedLayerId.value)
|
||||
}
|
||||
else {
|
||||
setSelection(nextIds, nextIds[0] ?? null)
|
||||
}
|
||||
|
||||
stopBoxSelect()
|
||||
}
|
||||
|
||||
function onBackgroundPointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0) {
|
||||
return false
|
||||
}
|
||||
const pointer = getPointerImagePosition(event.clientX, event.clientY)
|
||||
if (!pointer) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
pointer.imageX < 0
|
||||
|| pointer.imageY < 0
|
||||
|| pointer.imageX > canvasView.value.imageWidth
|
||||
|| pointer.imageY > canvasView.value.imageHeight
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
boxSelect.value.active = true
|
||||
boxSelect.value.additive = event.metaKey || event.ctrlKey || event.shiftKey
|
||||
boxSelect.value.moved = false
|
||||
boxSelect.value.startX = pointer.imageX
|
||||
boxSelect.value.startY = pointer.imageY
|
||||
boxSelect.value.currentX = pointer.imageX
|
||||
boxSelect.value.currentY = pointer.imageY
|
||||
pointerTracker.startClientX = event.clientX
|
||||
pointerTracker.startClientY = event.clientY
|
||||
pointerTracker.currentClientX = event.clientX
|
||||
pointerTracker.currentClientY = event.clientY
|
||||
// 点按时顺手命中分组,允许“单击组选中,拖拽时框选”的双重语义。
|
||||
const hitGroup = buildLayerGroups(layerList.value)
|
||||
.find(group => isPointInsideRect(pointer, group))
|
||||
pointerTracker.hitGroupId = hitGroup?.groupId ?? ''
|
||||
|
||||
window.addEventListener('pointermove', onBoxSelectMove)
|
||||
window.addEventListener('pointerup', onBoxSelectUp)
|
||||
return true
|
||||
}
|
||||
|
||||
listenEditorEmitter('selection:backgroundPointerDown', onBackgroundPointerDown)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopBoxSelect()
|
||||
})
|
||||
|
||||
return {
|
||||
onBackgroundPointerDown,
|
||||
stopBoxSelect,
|
||||
}
|
||||
}
|
||||
78
packages/core/src/components/editor/canvas/context/canvas.ts
Normal file
78
packages/core/src/components/editor/canvas/context/canvas.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { CanvasStageExpose } from '../types'
|
||||
import { inject, provide, shallowRef } from 'vue'
|
||||
import { unwrapElement } from '../utils'
|
||||
import { useCanvasState } from './state'
|
||||
|
||||
export type CanvasCursorMode = 'default' | 'move'
|
||||
|
||||
export type PointerPosition = {
|
||||
imageX: number
|
||||
imageY: number
|
||||
rect: DOMRect
|
||||
canvasX: number
|
||||
canvasY: number
|
||||
} | null
|
||||
|
||||
const CanvasProviderKey = Symbol('canvas-provider')
|
||||
|
||||
export function useCanvasProvider() {
|
||||
// 编辑器主区域的 ref,包含画布和背景等元素。用于事件监听时判断事件是否发生在编辑器主区域。
|
||||
const editorRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
// 画布 ref,包含画布元素和相关方法。用于在属性面板等子组件中访问和操作画布。
|
||||
const stageRef = shallowRef<CanvasStageExpose | null>(null)
|
||||
|
||||
function getWrapperElement() {
|
||||
return unwrapElement(stageRef.value?.wrapperRef)
|
||||
}
|
||||
|
||||
function getCanvasElement() {
|
||||
return unwrapElement(stageRef.value?.canvasRef) as HTMLCanvasElement
|
||||
}
|
||||
|
||||
const { canvasView } = useCanvasState()
|
||||
|
||||
function getPointerImagePosition(clientX: number, clientY: number): PointerPosition {
|
||||
const canvas = getCanvasElement()
|
||||
if (!canvas || canvasView.value.scale <= 0) {
|
||||
return null
|
||||
}
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const canvasX = clientX - rect.left
|
||||
const canvasY = clientY - rect.top
|
||||
|
||||
// 同时返回屏幕坐标和底图坐标,供拖拽放置、框选、缩放复用。
|
||||
return {
|
||||
imageX: (canvasX - canvasView.value.offsetX) / canvasView.value.scale,
|
||||
imageY: (canvasY - canvasView.value.offsetY) / canvasView.value.scale,
|
||||
rect,
|
||||
canvasX,
|
||||
canvasY,
|
||||
}
|
||||
}
|
||||
|
||||
// 属性面板 ref,包含属性面板元素和相关方法。用于在画布等父组件中访问和操作属性面板。
|
||||
const propertyPanelRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
// 左侧边栏 ref,包含图层、组件等编辑器侧边面板。
|
||||
const sidebarRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
const state = {
|
||||
editorRef,
|
||||
stageRef,
|
||||
propertyPanelRef,
|
||||
sidebarRef,
|
||||
getWrapperElement,
|
||||
getCanvasElement,
|
||||
getPointerImagePosition,
|
||||
}
|
||||
|
||||
provide(CanvasProviderKey, state)
|
||||
return state
|
||||
}
|
||||
|
||||
export type CanvasProviderState = ReturnType<typeof useCanvasProvider>
|
||||
|
||||
export function useCanvasInject() {
|
||||
return inject<CanvasProviderState>(CanvasProviderKey)!
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { inject, provide } from 'vue'
|
||||
|
||||
// ── 编辑器模式 ──
|
||||
export type EditorMode = 'editor' | 'runtime'
|
||||
|
||||
export const EditorModeKey: InjectionKey<EditorMode> = Symbol('editor-mode')
|
||||
|
||||
export function provideEditorMode(mode: EditorMode) {
|
||||
provide(EditorModeKey, mode)
|
||||
}
|
||||
|
||||
export function useEditorModeInject(): EditorMode {
|
||||
return inject(EditorModeKey, 'editor')
|
||||
}
|
||||
|
||||
// ── 预览缩放 ──
|
||||
export const PreviewScaleKey: InjectionKey<Ref<number>> = Symbol('preview-scale')
|
||||
|
||||
export function providePreviewScale(scale: Ref<number>) {
|
||||
provide(PreviewScaleKey, scale)
|
||||
}
|
||||
|
||||
export function usePreviewScaleInject(): Ref<number> | undefined {
|
||||
return inject(PreviewScaleKey, undefined)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './canvas'
|
||||
export * from './editor-mode'
|
||||
export * from './page'
|
||||
export * from './runtime'
|
||||
export * from './state'
|
||||
export * from './ui'
|
||||
54
packages/core/src/components/editor/canvas/context/page.ts
Normal file
54
packages/core/src/components/editor/canvas/context/page.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { inject, provide, ref } from 'vue'
|
||||
import pinia, { useCanvasStore } from '@/stores'
|
||||
|
||||
const CanvasPageKey = Symbol('page')
|
||||
export function useCanvasPageProvider() {
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
const { canvasList } = storeToRefs(canvasStore)
|
||||
|
||||
function findPageById(pageId: string) {
|
||||
return canvasList.value.find(page => page.id === pageId) || null
|
||||
}
|
||||
|
||||
// 画布管理弹窗(新增/重命名)
|
||||
const showPageDialog = ref(false)
|
||||
function changePageDialogVisible(visible: boolean) {
|
||||
showPageDialog.value = visible
|
||||
}
|
||||
|
||||
const editingPageId = ref<string | null>(null)
|
||||
|
||||
// 打开“新增画布”弹窗
|
||||
function openCreatePageDialog() {
|
||||
editingPageId.value = null
|
||||
showPageDialog.value = true
|
||||
}
|
||||
|
||||
// 打开“重命名画布”弹窗
|
||||
function openRenamePageDialog(pageId: string) {
|
||||
const page = findPageById(pageId)
|
||||
if (!page) {
|
||||
return
|
||||
}
|
||||
|
||||
editingPageId.value = pageId
|
||||
showPageDialog.value = true
|
||||
}
|
||||
|
||||
const state = {
|
||||
showPageDialog,
|
||||
editingPageId,
|
||||
changePageDialogVisible,
|
||||
openRenamePageDialog,
|
||||
openCreatePageDialog,
|
||||
}
|
||||
|
||||
provide(CanvasPageKey, state)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export function useCanvasPageInject() {
|
||||
return inject<ReturnType<typeof useCanvasPageProvider>>(CanvasPageKey)!
|
||||
}
|
||||
372
packages/core/src/components/editor/canvas/context/runtime.ts
Normal file
372
packages/core/src/components/editor/canvas/context/runtime.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import type { VariableValueType } from '@cslab-dcs/schema'
|
||||
import type { ComputedRef, InjectionKey, Ref } from 'vue'
|
||||
import type { DynamicProjectProperty } from '@/api'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, provide, ref, shallowRef, watch } from 'vue'
|
||||
import { getDynamicProjectModulePropsApi, getDynamicProjectModulesApi } from '@/api'
|
||||
import pinia, { useProjectStore } from '@/stores'
|
||||
import { useCanvasState } from './state'
|
||||
|
||||
export interface CanvasVariableOption {
|
||||
label: string
|
||||
value: string
|
||||
children?: CanvasVariableOption[]
|
||||
}
|
||||
|
||||
export interface CanvasRuntimeVariable {
|
||||
path: string
|
||||
moduleLabel: string
|
||||
moduleName: string
|
||||
moduleDescribe?: string
|
||||
propLabel: string
|
||||
propName: string
|
||||
describe?: string
|
||||
unit?: string
|
||||
type: VariableValueType
|
||||
defaultValue?: unknown
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export interface CanvasRuntimeState {
|
||||
isLoadingVariables: Ref<boolean>
|
||||
variableLoadError: Ref<string>
|
||||
variableOptions: ComputedRef<CanvasVariableOption[]>
|
||||
variableMap: ComputedRef<Record<string, CanvasRuntimeVariable>>
|
||||
backgroundSampleColor: ComputedRef<string>
|
||||
autoTextColor: ComputedRef<string>
|
||||
ensureVariableCatalogLoaded: (force?: boolean) => Promise<void>
|
||||
refreshVariableCatalog: () => Promise<void>
|
||||
setBackgroundAverageColor: (value: string | null) => void
|
||||
}
|
||||
|
||||
export const CanvasRuntimeKey: InjectionKey<CanvasRuntimeState> = Symbol('canvas-runtime')
|
||||
const HEX_COLOR_RE = /^#(?:[\dA-F]{3}|[\dA-F]{6})$/i
|
||||
|
||||
// 统一只接受十六进制颜色,便于后续自动推导文字颜色。
|
||||
function isHexColor(value: string) {
|
||||
return HEX_COLOR_RE.test(value.trim())
|
||||
}
|
||||
|
||||
function normalizeColor(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const normalized = value.trim()
|
||||
return isHexColor(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
export function getReadableTextColor(color: string) {
|
||||
const normalized = normalizeColor(color) || '#FFFFFF'
|
||||
const hex = normalized.slice(1)
|
||||
const expanded = hex.length === 3
|
||||
? hex.split('').map(char => `${char}${char}`).join('')
|
||||
: hex
|
||||
const red = Number.parseInt(expanded.slice(0, 2), 16)
|
||||
const green = Number.parseInt(expanded.slice(2, 4), 16)
|
||||
const blue = Number.parseInt(expanded.slice(4, 6), 16)
|
||||
const brightness = (red * 299 + green * 587 + blue * 114) / 1000
|
||||
return brightness >= 160 ? '#111827' : '#FFFFFF'
|
||||
}
|
||||
|
||||
function inferVariableType(property: DynamicProjectProperty): VariableValueType {
|
||||
// classify: 0-整数 1-浮点 2-字符串 3-列表 4-枚举 5-布尔 6-组分 7-已知变量
|
||||
switch (property.classify) {
|
||||
case '5':
|
||||
return 'digital'
|
||||
case '2':
|
||||
return 'string'
|
||||
case '4':
|
||||
return 'enum'
|
||||
default:
|
||||
return 'analog'
|
||||
}
|
||||
}
|
||||
|
||||
function hasLayerBindingValue(binding: string | { value?: string } | Array<string | { value?: string }> | undefined): boolean {
|
||||
if (!binding) {
|
||||
return false
|
||||
}
|
||||
if (Array.isArray(binding)) {
|
||||
return binding.some(item => hasLayerBindingValue(item))
|
||||
}
|
||||
if (typeof binding === 'string') {
|
||||
return Boolean(binding.trim())
|
||||
}
|
||||
return typeof binding.value === 'string' && Boolean(binding.value.trim())
|
||||
}
|
||||
|
||||
export function useCanvasRuntimeProvider() {
|
||||
const projectStore = useProjectStore(pinia)
|
||||
const { projectId, projectInfo } = storeToRefs(projectStore)
|
||||
const { canvasInfo, canvasView, layerList } = useCanvasState()
|
||||
|
||||
// 变量目录来自基础项目接口,和当前画布上已绑定的变量值分开维护。
|
||||
const runtimeVariables = shallowRef<CanvasRuntimeVariable[]>([])
|
||||
const isLoadingVariables = ref(false)
|
||||
const variableLoadError = ref('')
|
||||
const backgroundAverageColor = shallowRef<string | null>(null)
|
||||
const loadedBaseProjectId = ref('')
|
||||
// 通过递增 token 丢弃过期请求,避免快速切画布时旧响应覆盖新状态。
|
||||
let requestToken = 0
|
||||
|
||||
function setBackgroundAverageColor(value: string | null) {
|
||||
backgroundAverageColor.value = normalizeColor(value)
|
||||
}
|
||||
|
||||
const canvasVarMap = computed(() => {
|
||||
const map = new Map<string, unknown>()
|
||||
for (const variable of canvasInfo.value?.vars || []) {
|
||||
const name = variable.name?.trim()
|
||||
if (!name) {
|
||||
continue
|
||||
}
|
||||
if (variable.defaultValue !== undefined) {
|
||||
map.set(name, variable.defaultValue)
|
||||
}
|
||||
if (variable.sourceId?.trim()) {
|
||||
map.set(variable.sourceId.trim(), variable.defaultValue)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const variableMap = computed<Record<string, CanvasRuntimeVariable>>(() => {
|
||||
// 运行时变量以接口目录为主,画布上已有的默认值优先覆盖展示值。
|
||||
return runtimeVariables.value.reduce<Record<string, CanvasRuntimeVariable>>((result, item) => {
|
||||
const canvasValue = canvasVarMap.value.get(item.path) ?? canvasVarMap.value.get(item.propLabel)
|
||||
result[item.path] = {
|
||||
...item,
|
||||
value: canvasValue !== undefined ? canvasValue : item.defaultValue,
|
||||
}
|
||||
return result
|
||||
}, {})
|
||||
})
|
||||
|
||||
const variableOptions = computed<CanvasVariableOption[]>(() => {
|
||||
// 变量选择器按照模块分组,便于属性面板直接构造级联选项。
|
||||
const groups = new Map<string, CanvasVariableOption>()
|
||||
for (const item of runtimeVariables.value) {
|
||||
const group = groups.get(item.moduleName) || {
|
||||
label: `${item.moduleName}(${item.moduleLabel})`,
|
||||
value: item.moduleName,
|
||||
children: [],
|
||||
}
|
||||
group.children!.push({
|
||||
label: item.propName,
|
||||
value: item.path,
|
||||
})
|
||||
groups.set(item.moduleName, group)
|
||||
}
|
||||
|
||||
return Array.from(groups.values(), (group) => {
|
||||
group.children = group.children?.sort((left, right) => left.label.localeCompare(right.label))
|
||||
return group
|
||||
})
|
||||
})
|
||||
|
||||
const backgroundSampleColor = computed(() => {
|
||||
return backgroundAverageColor.value || normalizeColor(canvasView.value.background) || '#FFFFFF'
|
||||
})
|
||||
|
||||
const autoTextColor = computed(() => getReadableTextColor(backgroundSampleColor.value))
|
||||
|
||||
const hasBoundLayers = computed(() => {
|
||||
// 只有确实存在绑定的图层时才去拉变量目录,避免无意义请求。
|
||||
return layerList.value.some(layer => Object.values(layer.bindings || {}).some(binding => hasLayerBindingValue(binding)))
|
||||
})
|
||||
|
||||
async function resolveBaseProjectId() {
|
||||
if (!projectId.value) {
|
||||
console.warn('[canvas-runtime] resolveBaseProjectId: projectId 为空,无法加载变量目录')
|
||||
return ''
|
||||
}
|
||||
|
||||
const cachedBaseProjectId = projectInfo.value?.base_pro?.trim()
|
||||
if (cachedBaseProjectId) {
|
||||
return cachedBaseProjectId
|
||||
}
|
||||
|
||||
await projectStore.getProjectInfo()
|
||||
const resolved = projectInfo.value?.base_pro?.trim() || ''
|
||||
if (!resolved) {
|
||||
console.warn('[canvas-runtime] resolveBaseProjectId: 项目 base_pro 为空', {
|
||||
projectId: projectId.value,
|
||||
projectInfo: projectInfo.value,
|
||||
})
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function resetVariableCatalog() {
|
||||
requestToken += 1
|
||||
runtimeVariables.value = []
|
||||
isLoadingVariables.value = false
|
||||
loadedBaseProjectId.value = ''
|
||||
}
|
||||
|
||||
async function refreshVariableCatalog() {
|
||||
const baseProjectId = await resolveBaseProjectId()
|
||||
if (!baseProjectId) {
|
||||
resetVariableCatalog()
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoadingVariables.value && loadedBaseProjectId.value === baseProjectId) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentToken = ++requestToken
|
||||
isLoadingVariables.value = true
|
||||
variableLoadError.value = ''
|
||||
try {
|
||||
// 先取模块,再并行拉每个模块下的属性定义,最后拍平成路径字典。
|
||||
console.log('[canvas-runtime] 正在加载变量目录', { baseProjectId })
|
||||
const modules = await getDynamicProjectModulesApi(baseProjectId)
|
||||
console.log('[canvas-runtime] 模块列表', { count: modules.length, modules })
|
||||
if (!modules.length) {
|
||||
console.warn('[canvas-runtime] module list is empty', { baseProjectId })
|
||||
}
|
||||
|
||||
// 同 label 的模块属性定义完全一致,按 label 去重请求,避免重复调用。
|
||||
const labelToPk = new Map<string, string>()
|
||||
for (const m of modules) {
|
||||
if (!labelToPk.has(m.label)) {
|
||||
labelToPk.set(m.label, m.module_pk)
|
||||
}
|
||||
}
|
||||
const propsByLabel = new Map<string, DynamicProjectProperty[]>()
|
||||
await Promise.all(
|
||||
Array.from(labelToPk.entries(), async ([label, modulePk]) => {
|
||||
try {
|
||||
const props = await getDynamicProjectModulePropsApi(baseProjectId, modulePk)
|
||||
if (!props.length) {
|
||||
console.warn('[canvas-runtime] module props are empty', {
|
||||
baseProjectId,
|
||||
moduleLabel: label,
|
||||
modulePk,
|
||||
})
|
||||
}
|
||||
propsByLabel.set(label, props)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[canvas-runtime] failed to load module props', label, error)
|
||||
propsByLabel.set(label, [])
|
||||
}
|
||||
}),
|
||||
)
|
||||
const propGroups = modules.map(module => ({
|
||||
module,
|
||||
props: propsByLabel.get(module.label) || [],
|
||||
}))
|
||||
|
||||
if (currentToken !== requestToken) {
|
||||
return
|
||||
}
|
||||
|
||||
// 按 path 去重:模块列表或属性列表可能包含重复项,只保留首次出现的
|
||||
const seenPaths = new Set<string>()
|
||||
runtimeVariables.value = propGroups.flatMap(({ module, props }) => {
|
||||
return props.reduce<CanvasRuntimeVariable[]>((acc, prop) => {
|
||||
const path = `${module.name}.${prop.name}`
|
||||
if (!seenPaths.has(path)) {
|
||||
seenPaths.add(path)
|
||||
acc.push({
|
||||
path,
|
||||
moduleLabel: module.label,
|
||||
moduleName: module.name,
|
||||
moduleDescribe: module.describe,
|
||||
propLabel: prop.name,
|
||||
propName: prop.displayLabel,
|
||||
describe: prop.describe,
|
||||
type: inferVariableType(prop),
|
||||
defaultValue: prop.defaultValue,
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
})
|
||||
|
||||
if (!runtimeVariables.value.length) {
|
||||
variableLoadError.value = modules.length
|
||||
? `已加载 ${modules.length} 个模块,但未获取到属性数据`
|
||||
: '模块列表为空,请检查基础项目配置'
|
||||
console.warn('[canvas-runtime] runtime variable catalog is empty after normalization', {
|
||||
baseProjectId,
|
||||
moduleCount: modules.length,
|
||||
propGroupCount: propGroups.length,
|
||||
})
|
||||
}
|
||||
loadedBaseProjectId.value = baseProjectId
|
||||
}
|
||||
catch (error) {
|
||||
if (currentToken === requestToken) {
|
||||
runtimeVariables.value = []
|
||||
loadedBaseProjectId.value = ''
|
||||
variableLoadError.value = `加载变量失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||
}
|
||||
console.error('[canvas-runtime] failed to load variable catalog', error)
|
||||
}
|
||||
finally {
|
||||
if (currentToken === requestToken) {
|
||||
isLoadingVariables.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureVariableCatalogLoaded(force = false) {
|
||||
const baseProjectId = await resolveBaseProjectId()
|
||||
if (!baseProjectId) {
|
||||
resetVariableCatalog()
|
||||
variableLoadError.value = '项目未关联动态项目,无法加载变量目录'
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && loadedBaseProjectId.value === baseProjectId) {
|
||||
return
|
||||
}
|
||||
|
||||
await refreshVariableCatalog()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => projectId.value,
|
||||
(nextProjectId, previousProjectId) => {
|
||||
// 项目切换后变量目录必须整体失效,防止串用上一项目的变量。
|
||||
if (!nextProjectId || nextProjectId !== previousProjectId) {
|
||||
resetVariableCatalog()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [projectId.value, hasBoundLayers.value],
|
||||
([nextProjectId, nextHasBoundLayers]) => {
|
||||
// 只有项目已就绪且画布真的用到了绑定变量时,才懒加载变量目录。
|
||||
if (!nextProjectId || !nextHasBoundLayers) {
|
||||
return
|
||||
}
|
||||
void ensureVariableCatalogLoaded()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const state: CanvasRuntimeState = {
|
||||
isLoadingVariables,
|
||||
variableLoadError,
|
||||
variableOptions,
|
||||
variableMap,
|
||||
backgroundSampleColor,
|
||||
autoTextColor,
|
||||
ensureVariableCatalogLoaded,
|
||||
refreshVariableCatalog,
|
||||
setBackgroundAverageColor,
|
||||
}
|
||||
|
||||
provide(CanvasRuntimeKey, state)
|
||||
return state
|
||||
}
|
||||
|
||||
export function useCanvasRuntimeInject() {
|
||||
return inject(CanvasRuntimeKey)!
|
||||
}
|
||||
46
packages/core/src/components/editor/canvas/context/state.ts
Normal file
46
packages/core/src/components/editor/canvas/context/state.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import pinia, { useCanvasStore, useLayerStore } from '@/stores'
|
||||
|
||||
export function useCanvasState() {
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
const {
|
||||
canvasId,
|
||||
canvasList,
|
||||
canvasZoom,
|
||||
isHydratingCanvas,
|
||||
canvasView,
|
||||
activeCanvasThumbnail,
|
||||
canvasInfo,
|
||||
} = storeToRefs(canvasStore)
|
||||
|
||||
const layerStore = useLayerStore(pinia)
|
||||
const {
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
selectedGroupId,
|
||||
isBaseLayerActive,
|
||||
selectedLayer,
|
||||
selectedGroup,
|
||||
layerList,
|
||||
boxSelect,
|
||||
} = storeToRefs(layerStore)
|
||||
|
||||
// 将两个 store 的核心响应式字段收敛到一个组合入口,便于编辑器各模块共享。
|
||||
return {
|
||||
canvasId,
|
||||
canvasList,
|
||||
canvasZoom,
|
||||
isHydratingCanvas,
|
||||
canvasView,
|
||||
activeCanvasThumbnail,
|
||||
canvasInfo,
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
selectedGroupId,
|
||||
isBaseLayerActive,
|
||||
selectedLayer,
|
||||
selectedGroup,
|
||||
layerList,
|
||||
boxSelect,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Layer } from '../types'
|
||||
import { computed, ref } from 'vue'
|
||||
import pinia, { useLayerStore } from '@/stores'
|
||||
import { useLayerSelection } from '../composables/layer'
|
||||
import { useCanvasState } from '../context/state'
|
||||
import { getLayerGroupById, getLayerGroupMeta, remapLayerGroupIds } from '../grouping'
|
||||
import { clamp, cloneValue, createLayerId, snapToGrid } from '../utils'
|
||||
|
||||
const GRID_SIZE = 10
|
||||
const PASTE_OFFSET = 20
|
||||
|
||||
export function useClipboard() {
|
||||
const { canvasView, layerList, selectedLayerIds } = useCanvasState()
|
||||
const layerStore = useLayerStore(pinia)
|
||||
|
||||
const copiedLayers = ref<Layer[]>([])
|
||||
const pasteRound = ref(0)
|
||||
const hasCopiedLayers = computed(() => copiedLayers.value.length > 0)
|
||||
|
||||
function copySelected() {
|
||||
if (!selectedLayerIds.value.length) {
|
||||
copiedLayers.value = []
|
||||
return
|
||||
}
|
||||
const selectedSet = new Set(selectedLayerIds.value)
|
||||
copiedLayers.value = layerList.value
|
||||
.filter(layer => selectedSet.has(layer.id))
|
||||
.map(layer => cloneValue(layer))
|
||||
pasteRound.value = 0
|
||||
}
|
||||
|
||||
const { setSelection } = useLayerSelection()
|
||||
function pasteCopied() {
|
||||
if (!copiedLayers.value.length || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
pasteRound.value += 1
|
||||
const offset = PASTE_OFFSET * pasteRound.value
|
||||
|
||||
const nextLayers = remapLayerGroupIds(copiedLayers.value).map((layer) => {
|
||||
const nextX = clamp(snapToGrid(layer.x + offset, GRID_SIZE), 0, Math.max(0, canvasView.value.imageWidth - layer.width))
|
||||
const nextY = clamp(snapToGrid(layer.y + offset, GRID_SIZE), 0, Math.max(0, canvasView.value.imageHeight - layer.height))
|
||||
return {
|
||||
...cloneValue(layer),
|
||||
id: createLayerId(),
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
}
|
||||
})
|
||||
|
||||
layerStore.appendLayers(nextLayers)
|
||||
const nextIds = nextLayers.map(layer => layer.id)
|
||||
const pastedGroupId = nextLayers[0] ? getLayerGroupMeta(nextLayers[0])?.groupId ?? '' : ''
|
||||
const pastedGroup = getLayerGroupById(nextLayers, pastedGroupId)
|
||||
if (pastedGroup && pastedGroup.layerIds.length === nextIds.length && nextIds.length > 1) {
|
||||
setSelection(nextIds, nextIds[0] ?? null, { groupId: pastedGroup.groupId })
|
||||
return
|
||||
}
|
||||
setSelection(nextIds, nextIds[0] ?? null)
|
||||
}
|
||||
|
||||
function resetClipboard() {
|
||||
copiedLayers.value = []
|
||||
pasteRound.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
copySelected,
|
||||
pasteCopied,
|
||||
resetClipboard,
|
||||
hasCopiedLayers,
|
||||
}
|
||||
}
|
||||
36
packages/core/src/components/editor/canvas/context/ui.ts
Normal file
36
packages/core/src/components/editor/canvas/context/ui.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { computed, inject, provide, ref } from 'vue'
|
||||
import { useClipboard } from './ui.clipboard'
|
||||
|
||||
const EditorUIKey = Symbol('ui')
|
||||
export function useEditorUIProvider() {
|
||||
// 画布工具状态
|
||||
const activeCanvasTool = ref('cursor')
|
||||
// 光标样式直接由工具态派生,避免模板层再写一套判断。
|
||||
const canvasCursorMode = computed(() => (activeCanvasTool.value === 'move' ? 'move' : 'default'))
|
||||
// 设置当前激活的画布工具
|
||||
function setActiveCanvasTool(tool: string) {
|
||||
activeCanvasTool.value = tool
|
||||
}
|
||||
|
||||
// 是否按下空格键(用于临时切换到抓手工具)
|
||||
const isSpacePressed = ref(false)
|
||||
|
||||
const clipboard = useClipboard()
|
||||
const state = {
|
||||
activeCanvasTool,
|
||||
canvasCursorMode,
|
||||
setActiveCanvasTool,
|
||||
isSpacePressed,
|
||||
clipboard,
|
||||
}
|
||||
|
||||
provide(EditorUIKey, state)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export type CanvasUIProvicerState = ReturnType<typeof useEditorUIProvider>
|
||||
|
||||
export function useEditorUIInject() {
|
||||
return inject<CanvasUIProvicerState>(EditorUIKey)!
|
||||
}
|
||||
92
packages/core/src/components/editor/canvas/emitter.ts
Normal file
92
packages/core/src/components/editor/canvas/emitter.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ComponentType } from '@cslab-dcs/schema'
|
||||
import type { ResizeHandle } from './types'
|
||||
import mitt from 'mitt'
|
||||
|
||||
interface PositionUpdate {
|
||||
id: string
|
||||
nextX: number
|
||||
nextY: number
|
||||
}
|
||||
|
||||
interface RectUpdate extends PositionUpdate {
|
||||
nextWidth: number
|
||||
nextHeight: number
|
||||
options?: { ignoreLock?: boolean }
|
||||
}
|
||||
|
||||
interface ViewportTargetBounds {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface EditorEventMap {
|
||||
'componentPalette:dragStart': { event: DragEvent, type: ComponentType }
|
||||
'componentPalette:pointerDown': { event: PointerEvent, type: ComponentType }
|
||||
'canvasStage:dragOver': DragEvent
|
||||
'canvasStage:drop': { event: DragEvent, type: string }
|
||||
'canvasStage:backgroundPointerDown': PointerEvent
|
||||
'canvasStage:layerClick': { event: MouseEvent, id: string }
|
||||
'canvasStage:layerPointerDown': { event: PointerEvent, id: string }
|
||||
'canvasStage:layerResizePointerDown': { event: PointerEvent, id: string, handle: ResizeHandle }
|
||||
'canvasStage:groupPointerDown': { event: PointerEvent, groupId: string }
|
||||
'canvasStage:groupResizePointerDown': { event: PointerEvent, groupId: string, handle: ResizeHandle }
|
||||
'canvasStage:baseLayerResizePointerDown': { event: PointerEvent, handle: ResizeHandle }
|
||||
'selection:backgroundPointerDown': PointerEvent
|
||||
'baseLayer:setSize': { width: number, height: number, options?: { resetView?: boolean } }
|
||||
'viewport:beginPan': PointerEvent
|
||||
'viewport:setScale': { newScale: number, anchor?: { clientX: number, clientY: number } }
|
||||
'viewport:panBy': { deltaX: number, deltaY: number }
|
||||
'viewport:centerTarget': ViewportTargetBounds
|
||||
'layer:updatePosition': { id: string, nextX: number, nextY: number }
|
||||
'layer:updatePositions': PositionUpdate[]
|
||||
'layer:updateRect': RectUpdate
|
||||
'layer:updateRects': RectUpdate[]
|
||||
'layer:updateField': { id: string, field: string, value: unknown, options?: { ignoreLock?: boolean } }
|
||||
'layer:updateFields': { ids: string[], field: string, value: unknown, options?: { ignoreLock?: boolean } }
|
||||
'propertyPanel:panelPointerdown': PointerEvent
|
||||
'propertyPanel:suspend': undefined
|
||||
'propertyPanel:resume': undefined
|
||||
'propertyPanel:openPanel': undefined
|
||||
'propertyPanel:closePanel': undefined
|
||||
'propertyPanel:replaceBackground': { source: string }
|
||||
'propertyPanel:removeBackground': undefined
|
||||
'header:undo': undefined
|
||||
'header:redo': undefined
|
||||
'history:stateChange': { canUndo: boolean, canRedo: boolean, undoLabel: string, redoLabel: string }
|
||||
'history:push': { label: string }
|
||||
}
|
||||
|
||||
type EventName = keyof EditorEventMap
|
||||
type VoidEventName = {
|
||||
[K in EventName]: EditorEventMap[K] extends undefined ? K : never
|
||||
}[EventName]
|
||||
type ValueEventName = Exclude<EventName, VoidEventName>
|
||||
|
||||
const emitter = mitt<Record<string, unknown>>()
|
||||
|
||||
export function setEditorEmitter<K extends VoidEventName>(type: K): void
|
||||
export function setEditorEmitter<K extends ValueEventName>(type: K, value: EditorEventMap[K]): void
|
||||
export function setEditorEmitter<K extends EventName>(type: K, value?: EditorEventMap[K]) {
|
||||
// 编辑器内部模块通过统一事件总线通信,尽量避免跨层级直接互相引用。
|
||||
emitter.emit(type, value as unknown)
|
||||
}
|
||||
|
||||
export function listenEditorEmitter<K extends EventName>(
|
||||
type: K,
|
||||
handler: (value: EditorEventMap[K]) => void,
|
||||
) {
|
||||
emitter.on(type, handler as (value: unknown) => void)
|
||||
}
|
||||
|
||||
export function removeEditorEmitter(types: EventName[] = []) {
|
||||
if (!types.length) {
|
||||
emitter.all.clear()
|
||||
return
|
||||
}
|
||||
|
||||
types.forEach((type) => {
|
||||
emitter.all.delete(type)
|
||||
})
|
||||
}
|
||||
221
packages/core/src/components/editor/canvas/grouping.ts
Normal file
221
packages/core/src/components/editor/canvas/grouping.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Layer, LayerGroup } from './types'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
const MIXED_VALUE = Symbol('mixed-value')
|
||||
|
||||
export function asRecord(value: unknown): UnknownRecord | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
return value as UnknownRecord
|
||||
}
|
||||
|
||||
function readStringValue(source: UnknownRecord | null, keys: string[]) {
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value !== 'string') {
|
||||
continue
|
||||
}
|
||||
const normalized = value.trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function createLayerGroupId() {
|
||||
return `group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
export function getLayerGroupMeta(layer: Layer): { groupId: string, groupName: string } | null {
|
||||
const layerRecord = asRecord(layer)
|
||||
const configRecord = asRecord(layer.config)
|
||||
const layerMetadataRecord = asRecord(layerRecord?.metadata)
|
||||
const configMetadataRecord = asRecord(configRecord?.metadata)
|
||||
const sources = [layerRecord, layerMetadataRecord, configRecord, configMetadataRecord]
|
||||
|
||||
for (const source of sources) {
|
||||
const groupId = readStringValue(source, ['groupId', 'group_id'])
|
||||
if (!groupId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const groupName = readStringValue(source, ['groupName', 'group_name', 'groupLabel'])
|
||||
|| `分组-${groupId.slice(-6)}`
|
||||
return {
|
||||
groupId,
|
||||
groupName,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function withLayerGroupMeta(
|
||||
layer: Layer,
|
||||
meta: { groupId: string, groupName: string } | null,
|
||||
) {
|
||||
const currentMetadata = asRecord(layer.config?.metadata) ?? {}
|
||||
const nextMetadata = {
|
||||
...currentMetadata,
|
||||
}
|
||||
|
||||
delete nextMetadata.groupId
|
||||
delete nextMetadata.groupName
|
||||
delete nextMetadata.group_id
|
||||
delete nextMetadata.group_name
|
||||
|
||||
if (meta) {
|
||||
nextMetadata.groupId = meta.groupId
|
||||
nextMetadata.groupName = meta.groupName
|
||||
}
|
||||
|
||||
return {
|
||||
...layer,
|
||||
config: {
|
||||
...layer.config,
|
||||
metadata: nextMetadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLayerGroups(layers: Layer[]): LayerGroup[] {
|
||||
const groups = new Map<string, LayerGroup>()
|
||||
|
||||
layers.forEach((layer) => {
|
||||
const meta = getLayerGroupMeta(layer)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = groups.get(meta.groupId)
|
||||
if (existing) {
|
||||
// 分组包围盒在遍历过程中持续扩张,避免额外二次扫描。
|
||||
const minX = Math.min(existing.x, layer.x)
|
||||
const minY = Math.min(existing.y, layer.y)
|
||||
const maxX = Math.max(existing.x + existing.width, layer.x + layer.width)
|
||||
const maxY = Math.max(existing.y + existing.height, layer.y + layer.height)
|
||||
existing.layerIds.push(layer.id)
|
||||
existing.layers.push(layer)
|
||||
existing.x = minX
|
||||
existing.y = minY
|
||||
existing.width = maxX - minX
|
||||
existing.height = maxY - minY
|
||||
return
|
||||
}
|
||||
|
||||
groups.set(meta.groupId, {
|
||||
groupId: meta.groupId,
|
||||
groupName: meta.groupName,
|
||||
layerIds: [layer.id],
|
||||
layers: [layer],
|
||||
x: layer.x,
|
||||
y: layer.y,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
})
|
||||
})
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
export function getLayerGroupById(layers: Layer[], groupId: string) {
|
||||
if (!groupId) {
|
||||
return null
|
||||
}
|
||||
return buildLayerGroups(layers).find(group => group.groupId === groupId) ?? null
|
||||
}
|
||||
|
||||
export function getLayerGroupByLayerId(layers: Layer[], layerId: string) {
|
||||
if (!layerId) {
|
||||
return null
|
||||
}
|
||||
return buildLayerGroups(layers).find(group => group.layerIds.includes(layerId)) ?? null
|
||||
}
|
||||
|
||||
export function remapLayerGroupIds(layers: Layer[]) {
|
||||
const groupIdMap = new Map<string, string>()
|
||||
|
||||
return layers.map((layer) => {
|
||||
const meta = getLayerGroupMeta(layer)
|
||||
if (!meta) {
|
||||
return layer
|
||||
}
|
||||
|
||||
const nextGroupId = groupIdMap.get(meta.groupId) ?? createLayerGroupId()
|
||||
if (!groupIdMap.has(meta.groupId)) {
|
||||
groupIdMap.set(meta.groupId, nextGroupId)
|
||||
}
|
||||
|
||||
return withLayerGroupMeta(layer, {
|
||||
groupId: nextGroupId,
|
||||
groupName: meta.groupName,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function buildGroupPositionUpdates(
|
||||
layers: Layer[],
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
) {
|
||||
return layers.map(layer => ({
|
||||
id: layer.id,
|
||||
nextX: Math.round(layer.x + deltaX),
|
||||
nextY: Math.round(layer.y + deltaY),
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildGroupRectUpdates(
|
||||
layers: Layer[],
|
||||
currentBounds: Pick<LayerGroup, 'x' | 'y' | 'width' | 'height'>,
|
||||
nextBounds: Pick<LayerGroup, 'x' | 'y' | 'width' | 'height'>,
|
||||
) {
|
||||
// 分组缩放按相对位置和比例计算成员新矩形,保持组内布局关系不变。
|
||||
const widthRatio = currentBounds.width === 0 ? 1 : nextBounds.width / currentBounds.width
|
||||
const heightRatio = currentBounds.height === 0 ? 1 : nextBounds.height / currentBounds.height
|
||||
|
||||
return layers.map((layer) => {
|
||||
const relativeX = currentBounds.width === 0 ? 0 : (layer.x - currentBounds.x) / currentBounds.width
|
||||
const relativeY = currentBounds.height === 0 ? 0 : (layer.y - currentBounds.y) / currentBounds.height
|
||||
return {
|
||||
id: layer.id,
|
||||
nextX: Math.round(nextBounds.x + relativeX * nextBounds.width),
|
||||
nextY: Math.round(nextBounds.y + relativeY * nextBounds.height),
|
||||
nextWidth: Math.round(layer.width * widthRatio),
|
||||
nextHeight: Math.round(layer.height * heightRatio),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isSameValue(left: unknown, right: unknown) {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
export function getCommonLayerFieldValue(layers: Layer[], field: string) {
|
||||
if (!layers.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [firstLayer, ...restLayers] = layers
|
||||
const firstValue = get(firstLayer, field)
|
||||
|
||||
// 属性面板批量编辑时,用特殊标记区分“真的空值”和“多值混合”。
|
||||
if (restLayers.every(layer => isSameValue(get(layer, field), firstValue))) {
|
||||
return firstValue
|
||||
}
|
||||
|
||||
return MIXED_VALUE
|
||||
}
|
||||
|
||||
export function isMixedGroupFieldValue(value: unknown): value is typeof MIXED_VALUE {
|
||||
return value === MIXED_VALUE
|
||||
}
|
||||
53
packages/core/src/components/editor/canvas/index.vue
Normal file
53
packages/core/src/components/editor/canvas/index.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useCanvasStore } from '@/stores'
|
||||
import PageFormDialog from './components/page-form-dialog.vue'
|
||||
import { useCanvasPageProvider, useCanvasProvider, useCanvasRuntimeProvider, useEditorUIProvider } from './context'
|
||||
import { removeEditorEmitter } from './emitter'
|
||||
import { useKeyAndPointerListener, usePageChangeListener, useWheelListener } from './listeners'
|
||||
import { CanvasStage, EditorSidebar, PropertyPanel } from './modules'
|
||||
import CanvasMinimap from './modules/minimap/index.vue'
|
||||
|
||||
// 加载画布列表,确保切换画布时数据已准备就绪
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.getCanvasList()
|
||||
|
||||
const provider = useCanvasProvider()
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const { editorRef, stageRef, propertyPanelRef, sidebarRef } = provider
|
||||
|
||||
useCanvasRuntimeProvider()
|
||||
|
||||
const uiProvider = useEditorUIProvider()
|
||||
|
||||
// 事件监听
|
||||
useKeyAndPointerListener(provider, uiProvider)
|
||||
useWheelListener(editorRef)
|
||||
usePageChangeListener()
|
||||
|
||||
// 画布页签管理
|
||||
useCanvasPageProvider()
|
||||
|
||||
// 组件卸载时清理所有监听器和事件
|
||||
onBeforeUnmount(removeEditorEmitter)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="editorRef" class="canvas-editor relative w-full h-full flex">
|
||||
<EditorSidebar ref="sidebarRef" class="flex-shrink-0" />
|
||||
|
||||
<div class="relative flex-1 min-w-0 h-full">
|
||||
<CanvasStage ref="stageRef" class="w-full h-full" />
|
||||
<CanvasMinimap />
|
||||
</div>
|
||||
|
||||
<PropertyPanel ref="propertyPanelRef" />
|
||||
|
||||
<PageFormDialog />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import '../styles/editor-tokens.css';
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './key-pointer'
|
||||
export * from './page'
|
||||
export * from './wheel'
|
||||
@@ -0,0 +1,435 @@
|
||||
import type { ComponentType } from '@cslab-dcs/schema'
|
||||
import type { CanvasProviderState, CanvasUIProvicerState } from '../context'
|
||||
import type { ResizeHandle } from '../types'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { MAX_CANVAS_SIZE, MIN_CANVAS_HEIGHT, MIN_CANVAS_WIDTH } from '@/constants'
|
||||
import pinia, { useLayerStore } from '@/stores'
|
||||
import { useLayerSelection } from '../composables/layer'
|
||||
import { useCanvasState } from '../context/state'
|
||||
import { listenEditorEmitter, setEditorEmitter } from '../emitter'
|
||||
|
||||
// 工具栏创建工具 ID → 组件类型的映射
|
||||
const CREATION_TOOL_TYPE_MAP: Record<string, ComponentType> = {
|
||||
frame: 'text',
|
||||
rect: 'rect',
|
||||
number: 'number',
|
||||
button: 'button',
|
||||
}
|
||||
|
||||
function isEditableElement(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
return target.isContentEditable
|
||||
|| target.tagName === 'INPUT'
|
||||
|| target.tagName === 'TEXTAREA'
|
||||
|| target.tagName === 'SELECT'
|
||||
}
|
||||
|
||||
function hasNativeTextSelection() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
return Boolean(selection && !selection.isCollapsed && selection.toString().trim())
|
||||
}
|
||||
|
||||
function resolveHtmlElement(target: EventTarget | null) {
|
||||
return target instanceof HTMLElement ? target : null
|
||||
}
|
||||
|
||||
function isLayerCopyShortcutContext(provider: CanvasProviderState, event: KeyboardEvent) {
|
||||
const stageWrapper = provider.getWrapperElement()
|
||||
const candidates = [
|
||||
resolveHtmlElement(event.target),
|
||||
resolveHtmlElement(typeof document !== 'undefined' ? document.activeElement : null),
|
||||
].filter(Boolean) as HTMLElement[]
|
||||
|
||||
return candidates.some((candidate) => {
|
||||
if (stageWrapper?.contains(candidate)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(candidate.closest('[data-layer-list-shortcuts="true"]'))
|
||||
})
|
||||
}
|
||||
|
||||
// 底图锁定宽高比时,拖动单边控制点也要回推另一边的尺寸。
|
||||
function normalizeLockedSizeByWidth(width: number, ratio: number) {
|
||||
let nextWidth = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, Math.round(width)))
|
||||
let nextHeight = Math.round(nextWidth / ratio)
|
||||
|
||||
if (nextHeight < MIN_CANVAS_HEIGHT) {
|
||||
nextHeight = MIN_CANVAS_HEIGHT
|
||||
nextWidth = Math.round(nextHeight * ratio)
|
||||
}
|
||||
if (nextWidth < MIN_CANVAS_WIDTH) {
|
||||
nextWidth = MIN_CANVAS_WIDTH
|
||||
nextHeight = Math.round(nextWidth / ratio)
|
||||
}
|
||||
if (nextHeight > MAX_CANVAS_SIZE) {
|
||||
nextHeight = MAX_CANVAS_SIZE
|
||||
nextWidth = Math.round(nextHeight * ratio)
|
||||
}
|
||||
if (nextWidth > MAX_CANVAS_SIZE) {
|
||||
nextWidth = MAX_CANVAS_SIZE
|
||||
nextHeight = Math.round(nextWidth / ratio)
|
||||
}
|
||||
|
||||
return {
|
||||
nextWidth: Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, nextWidth)),
|
||||
nextHeight: Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, nextHeight)),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLockedSizeByHeight(height: number, ratio: number) {
|
||||
let nextHeight = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, Math.round(height)))
|
||||
let nextWidth = Math.round(nextHeight * ratio)
|
||||
|
||||
if (nextWidth < MIN_CANVAS_WIDTH) {
|
||||
nextWidth = MIN_CANVAS_WIDTH
|
||||
nextHeight = Math.round(nextWidth / ratio)
|
||||
}
|
||||
if (nextHeight < MIN_CANVAS_HEIGHT) {
|
||||
nextHeight = MIN_CANVAS_HEIGHT
|
||||
nextWidth = Math.round(nextHeight * ratio)
|
||||
}
|
||||
if (nextWidth > MAX_CANVAS_SIZE) {
|
||||
nextWidth = MAX_CANVAS_SIZE
|
||||
nextHeight = Math.round(nextWidth / ratio)
|
||||
}
|
||||
if (nextHeight > MAX_CANVAS_SIZE) {
|
||||
nextHeight = MAX_CANVAS_SIZE
|
||||
nextWidth = Math.round(nextHeight * ratio)
|
||||
}
|
||||
|
||||
return {
|
||||
nextWidth: Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, nextWidth)),
|
||||
nextHeight: Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, nextHeight)),
|
||||
}
|
||||
}
|
||||
|
||||
export function useKeyAndPointerListener(provider: CanvasProviderState, uiProvider: CanvasUIProvicerState, history?: { undo: () => void, redo: () => void }) {
|
||||
// 这里负责“全局输入”,包括快捷键、面板外拖放和底图缩放手柄。
|
||||
const isPointerDragging = ref(false)
|
||||
const fallbackDragType = ref<ComponentType>('number')
|
||||
|
||||
const baseLayerResizeState = reactive({
|
||||
active: false,
|
||||
handle: null as ResizeHandle | null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startWidth: 0,
|
||||
startHeight: 0,
|
||||
})
|
||||
|
||||
const { canvasView } = useCanvasState()
|
||||
const {
|
||||
clearSelection,
|
||||
removeSelectedLayers,
|
||||
bringSelectedToFront,
|
||||
sendSelectedToBack,
|
||||
groupSelected,
|
||||
ungroupSelected,
|
||||
placeLayerAtClientPoint,
|
||||
} = useLayerSelection()
|
||||
|
||||
useEventListener(window, 'pointerup', (event) => {
|
||||
if (!isPointerDragging.value) {
|
||||
return
|
||||
}
|
||||
isPointerDragging.value = false
|
||||
setEditorEmitter('propertyPanel:resume')
|
||||
|
||||
const pointer = provider.getPointerImagePosition(event.clientX, event.clientY)
|
||||
|
||||
// 非原生 drag 模式下,靠 pointerup 时的位置补一个”放入画布”动作。
|
||||
const placed = placeLayerAtClientPoint(pointer, fallbackDragType.value)
|
||||
if (placed) {
|
||||
setEditorEmitter('history:push', { label: '添加图层' })
|
||||
}
|
||||
})
|
||||
|
||||
function getPointerImagePosition(clientX: number, clientY: number) {
|
||||
const canvas = provider.getCanvasElement()
|
||||
if (!canvas || canvasView.value.scale <= 0) {
|
||||
return null
|
||||
}
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const canvasX = clientX - rect.left
|
||||
const canvasY = clientY - rect.top
|
||||
|
||||
return {
|
||||
imageX: (canvasX - canvasView.value.offsetX) / canvasView.value.scale,
|
||||
imageY: (canvasY - canvasView.value.offsetY) / canvasView.value.scale,
|
||||
}
|
||||
}
|
||||
|
||||
function onBaseLayerResizePointerMove(event: PointerEvent) {
|
||||
if (!baseLayerResizeState.active || !baseLayerResizeState.handle) {
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = getPointerImagePosition(event.clientX, event.clientY)
|
||||
if (!pointer) {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = pointer.imageX - baseLayerResizeState.startX
|
||||
const deltaY = pointer.imageY - baseLayerResizeState.startY
|
||||
let nextWidth = baseLayerResizeState.startWidth
|
||||
let nextHeight = baseLayerResizeState.startHeight
|
||||
|
||||
if (baseLayerResizeState.handle.includes('e')) {
|
||||
nextWidth = baseLayerResizeState.startWidth + deltaX
|
||||
}
|
||||
if (baseLayerResizeState.handle.includes('w')) {
|
||||
nextWidth = baseLayerResizeState.startWidth - deltaX
|
||||
}
|
||||
if (baseLayerResizeState.handle.includes('s')) {
|
||||
nextHeight = baseLayerResizeState.startHeight + deltaY
|
||||
}
|
||||
if (baseLayerResizeState.handle.includes('n')) {
|
||||
nextHeight = baseLayerResizeState.startHeight - deltaY
|
||||
}
|
||||
|
||||
if (canvasView.value.backgroundLockAspectRatio && baseLayerResizeState.startWidth > 0 && baseLayerResizeState.startHeight > 0) {
|
||||
const ratio = baseLayerResizeState.startWidth / baseLayerResizeState.startHeight
|
||||
const hasHorizontalHandle = baseLayerResizeState.handle.includes('e') || baseLayerResizeState.handle.includes('w')
|
||||
const hasVerticalHandle = baseLayerResizeState.handle.includes('n') || baseLayerResizeState.handle.includes('s')
|
||||
|
||||
if (hasHorizontalHandle && !hasVerticalHandle) {
|
||||
const normalized = normalizeLockedSizeByWidth(nextWidth, ratio)
|
||||
nextWidth = normalized.nextWidth
|
||||
nextHeight = normalized.nextHeight
|
||||
}
|
||||
else if (!hasHorizontalHandle && hasVerticalHandle) {
|
||||
const normalized = normalizeLockedSizeByHeight(nextHeight, ratio)
|
||||
nextWidth = normalized.nextWidth
|
||||
nextHeight = normalized.nextHeight
|
||||
}
|
||||
else {
|
||||
const widthRatioDelta = Math.abs(nextWidth - baseLayerResizeState.startWidth) / Math.max(1, baseLayerResizeState.startWidth)
|
||||
const heightRatioDelta = Math.abs(nextHeight - baseLayerResizeState.startHeight) / Math.max(1, baseLayerResizeState.startHeight)
|
||||
if (widthRatioDelta >= heightRatioDelta) {
|
||||
const normalized = normalizeLockedSizeByWidth(nextWidth, ratio)
|
||||
nextWidth = normalized.nextWidth
|
||||
nextHeight = normalized.nextHeight
|
||||
}
|
||||
else {
|
||||
const normalized = normalizeLockedSizeByHeight(nextHeight, ratio)
|
||||
nextWidth = normalized.nextWidth
|
||||
nextHeight = normalized.nextHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
nextWidth = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, Math.round(nextWidth)))
|
||||
nextHeight = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, Math.round(nextHeight)))
|
||||
}
|
||||
|
||||
// 底图尺寸统一通过 emitter 分发,和属性面板改尺寸共用一套入口。
|
||||
setEditorEmitter('baseLayer:setSize', { width: nextWidth, height: nextHeight })
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointermove', (event: PointerEvent) => {
|
||||
onBaseLayerResizePointerMove(event)
|
||||
})
|
||||
|
||||
function onBaseLayerResizePointerEnd() {
|
||||
if (!baseLayerResizeState.active) {
|
||||
return
|
||||
}
|
||||
baseLayerResizeState.active = false
|
||||
baseLayerResizeState.handle = null
|
||||
setEditorEmitter('propertyPanel:resume')
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerup', () => {
|
||||
onBaseLayerResizePointerEnd()
|
||||
})
|
||||
|
||||
useEventListener(window, 'pointercancel', () => {
|
||||
isPointerDragging.value = false
|
||||
setEditorEmitter('propertyPanel:resume')
|
||||
onBaseLayerResizePointerEnd()
|
||||
})
|
||||
|
||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space' && !isEditableElement(event.target)) {
|
||||
uiProvider.isSpacePressed.value = true
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableElement(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
const withMeta = event.metaKey || event.ctrlKey
|
||||
|
||||
if (withMeta && !event.shiftKey && key === 'z') {
|
||||
if (history) {
|
||||
history.undo()
|
||||
}
|
||||
else {
|
||||
setEditorEmitter('header:undo')
|
||||
}
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (withMeta && event.shiftKey && key === 'z') {
|
||||
if (history) {
|
||||
history.redo()
|
||||
}
|
||||
else {
|
||||
setEditorEmitter('header:redo')
|
||||
}
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// 编辑器快捷键只在非输入态生效,避免和表单输入冲突。
|
||||
if (withMeta && key === 'c') {
|
||||
if (hasNativeTextSelection()) {
|
||||
return
|
||||
}
|
||||
if (!isLayerCopyShortcutContext(provider, event)) {
|
||||
return
|
||||
}
|
||||
uiProvider.clipboard.copySelected()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (withMeta && key === 'v') {
|
||||
uiProvider.clipboard.pasteCopied()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
removeSelectedLayers().then(() => {
|
||||
setEditorEmitter('history:push', { label: '删除图层' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.altKey && event.key === ']') {
|
||||
bringSelectedToFront()
|
||||
setEditorEmitter('history:push', { label: '调整层级' })
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.altKey && event.key === '[') {
|
||||
sendSelectedToBack()
|
||||
setEditorEmitter('history:push', { label: '调整层级' })
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (withMeta && !event.shiftKey && key === 'g') {
|
||||
const grouped = groupSelected()
|
||||
if (grouped) {
|
||||
setEditorEmitter('history:push', { label: '创建分组' })
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (withMeta && event.shiftKey && key === 'g') {
|
||||
event.preventDefault()
|
||||
ungroupSelected().then((ungrouped) => {
|
||||
if (ungrouped) {
|
||||
setEditorEmitter('history:push', { label: '解除分组' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(window, 'keyup', (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') {
|
||||
uiProvider.isSpacePressed.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function onComponentPalettePointerDown({ event, type }: { event: PointerEvent, type: ComponentType }) {
|
||||
isPointerDragging.value = true
|
||||
fallbackDragType.value = type
|
||||
setEditorEmitter('propertyPanel:suspend')
|
||||
event.preventDefault()
|
||||
}
|
||||
listenEditorEmitter('componentPalette:pointerDown', onComponentPalettePointerDown)
|
||||
|
||||
const layerStore = useLayerStore(pinia)
|
||||
function onBaseLayerResizePointerDown(payload: { event: PointerEvent, handle: ResizeHandle }) {
|
||||
const { event, handle } = payload
|
||||
if (event.button !== 0 || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = getPointerImagePosition(event.clientX, event.clientY)
|
||||
if (!pointer) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setEditorEmitter('propertyPanel:suspend')
|
||||
baseLayerResizeState.active = true
|
||||
baseLayerResizeState.handle = handle
|
||||
baseLayerResizeState.startX = pointer.imageX
|
||||
baseLayerResizeState.startY = pointer.imageY
|
||||
baseLayerResizeState.startWidth = canvasView.value.imageWidth
|
||||
baseLayerResizeState.startHeight = canvasView.value.imageHeight
|
||||
layerStore.activateBaseLayerSelection()
|
||||
}
|
||||
listenEditorEmitter('canvasStage:baseLayerResizePointerDown', onBaseLayerResizePointerDown)
|
||||
|
||||
function isPointInsideBaseLayer(point: { imageX: number, imageY: number }) {
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return false
|
||||
}
|
||||
return point.imageX >= 0
|
||||
&& point.imageY >= 0
|
||||
&& point.imageX <= canvasView.value.imageWidth
|
||||
&& point.imageY <= canvasView.value.imageHeight
|
||||
}
|
||||
|
||||
function onBackgroundPointerDown(event: PointerEvent) {
|
||||
const shouldPan = event.button === 1
|
||||
|| (event.button === 0 && (uiProvider.isSpacePressed.value || uiProvider.activeCanvasTool.value === 'move'))
|
||||
if (shouldPan) {
|
||||
// 空格或抓手工具下,背景点击优先进入视口平移模式。
|
||||
setEditorEmitter('viewport:beginPan', event)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建工具模式:点击画布直接在落点处创建对应类型的图层,然后切回选择工具。
|
||||
const creationType = CREATION_TOOL_TYPE_MAP[uiProvider.activeCanvasTool.value]
|
||||
if (creationType && event.button === 0) {
|
||||
const pointer = provider.getPointerImagePosition(event.clientX, event.clientY)
|
||||
const placed = placeLayerAtClientPoint(pointer, creationType)
|
||||
if (placed) {
|
||||
setEditorEmitter('history:push', { label: '添加图层' })
|
||||
}
|
||||
uiProvider.setActiveCanvasTool('cursor')
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = provider.getPointerImagePosition(event.clientX, event.clientY)
|
||||
if (!pointer || !isPointInsideBaseLayer(pointer)) {
|
||||
layerStore.deactivateBaseLayerSelection()
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
layerStore.activateBaseLayerSelection()
|
||||
setEditorEmitter('selection:backgroundPointerDown', event)
|
||||
}
|
||||
listenEditorEmitter('canvasStage:backgroundPointerDown', onBackgroundPointerDown)
|
||||
}
|
||||
10
packages/core/src/components/editor/canvas/listeners/page.ts
Normal file
10
packages/core/src/components/editor/canvas/listeners/page.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useLayerPersistence } from '../composables/persistence'
|
||||
|
||||
export function usePageChangeListener() {
|
||||
const { persistComponentsLayerNow } = useLayerPersistence()
|
||||
useEventListener(window, 'pagehide', () => {
|
||||
// 页面被隐藏或刷新前尽量再落盘一次,降低“刚拖完就刷新”导致的数据丢失概率。
|
||||
persistComponentsLayerNow()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ShallowRef } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useCanvasState } from '../context/state'
|
||||
import { setEditorEmitter } from '../emitter'
|
||||
|
||||
// 鼠标位于侧边面板(图层/属性)时,不拦截滚轮,避免误触发画布平移。
|
||||
function isWheelOnPanel(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(target.closest('[data-editor-panel], .property-panel'))
|
||||
}
|
||||
|
||||
export function useWheelListener(editorRef: ShallowRef<HTMLElement | null>) {
|
||||
const { canvasView } = useCanvasState()
|
||||
|
||||
useEventListener(
|
||||
editorRef,
|
||||
'wheel',
|
||||
(event: WheelEvent) => {
|
||||
if (isWheelOnPanel(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault()
|
||||
// 组合键滚轮走缩放,并以鼠标位置作为锚点。
|
||||
const factor = event.deltaY > 0 ? 1 / 1.08 : 1.08
|
||||
const newScale = canvasView.value.scale * factor
|
||||
const anchor = { clientX: event.clientX, clientY: event.clientY }
|
||||
setEditorEmitter('viewport:setScale', { newScale, anchor })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const horizontalDelta = event.deltaX || (event.shiftKey ? event.deltaY : 0)
|
||||
const verticalDelta = event.deltaY
|
||||
if (!horizontalDelta && !verticalDelta) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
// 与常见滚动方向一致:滚轮向下时画面向上移动,向右时画面向左移动。
|
||||
const deltaX = -horizontalDelta
|
||||
const deltaY = -verticalDelta
|
||||
setEditorEmitter('viewport:panBy', { deltaX, deltaY })
|
||||
},
|
||||
{ passive: false },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ComponentPaletteItem } from './types'
|
||||
|
||||
export const COMPONENT_PALETTE_ITEMS: ComponentPaletteItem[] = [
|
||||
{ title: '棒图', type: 'bar', icon: 'fa-solid fa-mattress-pillow', description: '分段变色柱状指示' },
|
||||
{ title: '控制仪表', type: 'pidController', icon: 'fa-solid fa-gauge-high', description: 'PID 控制仪表面板' },
|
||||
{ title: '阀门控制器', type: 'valveController', icon: 'fa-solid fa-faucet', description: '阀门开度控制面板' },
|
||||
{ title: '切页按钮', type: 'canvasSwitcher', icon: 'fa-solid fa-layer-group', description: '画布 Tab 切换' },
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentType } from '@cslab-dcs/schema'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { COMPONENT_PALETTE_ITEMS } from './constants'
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'dragStart', event: DragEvent, type: ComponentType): void
|
||||
(e: 'dragEnd', event: DragEvent): void
|
||||
(e: 'pointerDown', event: PointerEvent, type: ComponentType): void
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { platformInfo } = storeToRefs(appStore)
|
||||
// Tauri 环境下优先走 pointer 方案,避免原生 drag/drop 兼容问题。
|
||||
const enableNativeDrag = computed(() => platformInfo.value?.name !== 'tauri')
|
||||
|
||||
function onComponentPaletteDragStart(event: DragEvent, type: ComponentType) {
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
emits('dragStart', event, type)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
event.dataTransfer.setData('application/json', JSON.stringify({ type }))
|
||||
}
|
||||
|
||||
function onComponentPaletteDragEnd(event: DragEvent) {
|
||||
emits('dragEnd', event)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent, type: ComponentType) {
|
||||
if (enableNativeDrag.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 非原生拖拽环境把“按下”当作拖入画布的起点,由全局监听完成投放。
|
||||
emits('pointerDown', event, type)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="components-tab">
|
||||
<p class="components-hint">
|
||||
拖拽添加到画布
|
||||
</p>
|
||||
|
||||
<div class="component-grid">
|
||||
<div
|
||||
v-for="item in COMPONENT_PALETTE_ITEMS"
|
||||
:key="item.type"
|
||||
class="component-card"
|
||||
:draggable="enableNativeDrag"
|
||||
@dragstart="onComponentPaletteDragStart($event, item.type)"
|
||||
@dragend="onComponentPaletteDragEnd"
|
||||
@pointerdown="onPointerDown($event, item.type)"
|
||||
>
|
||||
<div class="card-icon">
|
||||
<i :class="item.icon" />
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="card-title">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="card-desc">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.components-tab {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--ed-space-4);
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
|
||||
.components-hint {
|
||||
margin: 0;
|
||||
font-size: var(--ed-font-sm);
|
||||
color: var(--ed-text-secondary);
|
||||
font-weight: var(--ed-font-weight-semibold);
|
||||
}
|
||||
|
||||
.component-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--ed-space-3);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.component-card {
|
||||
border: 1px solid var(--ed-border);
|
||||
border-radius: var(--ed-radius-md);
|
||||
background: var(--ed-bg-raised);
|
||||
min-height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ed-space-4);
|
||||
padding: var(--ed-space-4);
|
||||
cursor: grab;
|
||||
transition: border-color var(--ed-transition-fast), box-shadow var(--ed-transition-fast), background-color var(--ed-transition-fast);
|
||||
}
|
||||
|
||||
.component-card:hover {
|
||||
border-color: var(--ed-accent);
|
||||
background: var(--ed-accent-subtle);
|
||||
box-shadow: var(--ed-shadow-sm);
|
||||
}
|
||||
|
||||
.component-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--ed-radius-sm);
|
||||
background: var(--ed-bg-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ed-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--ed-font-sm);
|
||||
font-weight: var(--ed-font-weight-semibold);
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: var(--ed-font-xs);
|
||||
color: var(--ed-text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ComponentType } from '@cslab-dcs/schema'
|
||||
|
||||
export interface ComponentPaletteItem {
|
||||
title: string
|
||||
type: ComponentType
|
||||
icon: string
|
||||
description?: string
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorSidebarToolItem } from '../types'
|
||||
import { reactive } from 'vue'
|
||||
import { useEditorUIInject } from '../../../context'
|
||||
import ContextMenu from '../../shared/context-menu.vue'
|
||||
import { EDITOR_SIDEBAR_TOOL_ITEMS } from '../constants'
|
||||
|
||||
const props = defineProps<{
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
}>()
|
||||
|
||||
const { activeCanvasTool, setActiveCanvasTool } = useEditorUIInject()
|
||||
|
||||
const toolDropdown = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
items: [] as Array<{ key: string, label?: string, type?: 'item' | 'separator', checked?: boolean }>,
|
||||
parentId: '',
|
||||
})
|
||||
|
||||
function openToolDropdown(event: MouseEvent, item: EditorSidebarToolItem) {
|
||||
if (!item.options?.length)
|
||||
return
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
toolDropdown.visible = true
|
||||
toolDropdown.x = rect.right + 4
|
||||
toolDropdown.y = rect.top
|
||||
toolDropdown.parentId = item.id
|
||||
toolDropdown.items = item.options.map(opt => ({
|
||||
key: opt.id,
|
||||
label: opt.title,
|
||||
checked: opt.id === activeCanvasTool.value,
|
||||
}))
|
||||
}
|
||||
|
||||
function closeToolDropdown() {
|
||||
toolDropdown.visible = false
|
||||
}
|
||||
|
||||
function onToolDropdownSelect(key: string) {
|
||||
setActiveCanvasTool(key)
|
||||
closeToolDropdown()
|
||||
}
|
||||
|
||||
function isToolActive(item: EditorSidebarToolItem) {
|
||||
if (item.options?.length) {
|
||||
return item.options.some(option => option.id === activeCanvasTool.value)
|
||||
}
|
||||
return item.id === activeCanvasTool.value
|
||||
}
|
||||
|
||||
function getActiveToolOption(item: EditorSidebarToolItem) {
|
||||
if (!item.options?.length) {
|
||||
return null
|
||||
}
|
||||
return item.options.find(option => option.id === activeCanvasTool.value) || item.options[0]
|
||||
}
|
||||
|
||||
function getToolTitle(item: EditorSidebarToolItem) {
|
||||
return getActiveToolOption(item)?.title || item.title
|
||||
}
|
||||
|
||||
function getToolIcon(item: EditorSidebarToolItem) {
|
||||
return getActiveToolOption(item)?.icon || item.icon
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="tool-rail">
|
||||
<div class="tool-list">
|
||||
<template v-for="item in EDITOR_SIDEBAR_TOOL_ITEMS" :key="item.id">
|
||||
<div v-if="item.dividerBefore" class="tool-divider" />
|
||||
|
||||
<button
|
||||
v-if="item.options?.length"
|
||||
type="button"
|
||||
class="tool-btn is-group"
|
||||
:class="{ active: isToolActive(item) }"
|
||||
:title="getToolTitle(item)"
|
||||
@click="openToolDropdown($event, item)"
|
||||
>
|
||||
<i :class="getToolIcon(item)" />
|
||||
<i class="fa-solid fa-angle-down tool-btn-caret" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="tool-btn"
|
||||
:class="{ active: isToolActive(item) }"
|
||||
:title="item.title"
|
||||
@click="setActiveCanvasTool(item.id)"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="tool-bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn"
|
||||
:title="props.collapsed ? '展开侧栏' : '收起侧栏'"
|
||||
@click="emits('toggle')"
|
||||
>
|
||||
<i class="fa-solid" :class="[props.collapsed ? 'fa-angles-right' : 'fa-angles-left']" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
:visible="toolDropdown.visible"
|
||||
:x="toolDropdown.x"
|
||||
:y="toolDropdown.y"
|
||||
:items="toolDropdown.items"
|
||||
@close="closeToolDropdown"
|
||||
@select="onToolDropdownSelect"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tool-rail {
|
||||
width: var(--ed-toolbar-width);
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--ed-border);
|
||||
background: var(--ed-bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tool-list,
|
||||
.tool-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
gap: var(--ed-space-1);
|
||||
}
|
||||
|
||||
.tool-divider {
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: var(--ed-border);
|
||||
margin: var(--ed-space-3) 0;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--ed-radius-md);
|
||||
color: var(--ed-text-primary);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background var(--ed-transition-fast), color var(--ed-transition-fast);
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: var(--ed-bg-hover);
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
color: var(--ed-accent);
|
||||
background: var(--ed-accent-subtle);
|
||||
}
|
||||
|
||||
.tool-btn.is-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-btn-caret {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
opacity: 0.75;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { EditorSidebarTabItem, EditorSidebarToolItem, EditorSidebarToolOption } from './types'
|
||||
|
||||
export const EDITOR_SIDEBAR_MOVE_TOOL_OPTIONS: EditorSidebarToolOption[] = [
|
||||
{ id: 'cursor', title: '选择工具', icon: 'fa-solid fa-arrow-pointer', shortcut: 'V' },
|
||||
{ id: 'move', title: '移动视图', icon: 'fa-regular fa-hand', shortcut: 'H' },
|
||||
]
|
||||
|
||||
export const EDITOR_SIDEBAR_TOOL_ITEMS: EditorSidebarToolItem[] = [
|
||||
{
|
||||
id: 'move-tool',
|
||||
title: '移动工具',
|
||||
icon: 'fa-solid fa-location-arrow',
|
||||
options: EDITOR_SIDEBAR_MOVE_TOOL_OPTIONS,
|
||||
},
|
||||
{ id: 'frame', title: '文本工具', icon: 'fa-solid fa-font', dividerBefore: true },
|
||||
{ id: 'rect', title: '矩形工具', icon: 'fa-regular fa-square' },
|
||||
{ id: 'number', title: '数值工具', icon: 'fa-solid fa-hashtag' },
|
||||
{ id: 'button', title: '按钮工具', icon: 'fa-regular fa-hand-pointer' },
|
||||
]
|
||||
|
||||
export const EDITOR_SIDEBAR_TABS: EditorSidebarTabItem[] = [
|
||||
{ key: 'layers', label: '图层' },
|
||||
{ key: 'components', label: '组件' },
|
||||
{ key: 'templates', label: '模板' },
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentType } from '@cslab-dcs/schema'
|
||||
import type { EditorSidebarTabKey } from './types'
|
||||
import { ref } from 'vue'
|
||||
import { setEditorEmitter } from '../../emitter'
|
||||
import ComponentPalette from '../component-palette/index.vue'
|
||||
import LayersPanel from '../layers-panel/index.vue'
|
||||
import TemplateLibrary from '../template-library/index.vue'
|
||||
import ToolRail from './components/tool-rail.vue'
|
||||
import { EDITOR_SIDEBAR_TABS } from './constants'
|
||||
|
||||
const activeTab = ref<EditorSidebarTabKey>('layers')
|
||||
|
||||
const collapsed = ref(false)
|
||||
function onToggle() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, type: ComponentType) {
|
||||
setEditorEmitter('propertyPanel:suspend')
|
||||
setEditorEmitter('componentPalette:dragStart', { event, type })
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
setEditorEmitter('propertyPanel:resume')
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent, type: ComponentType) {
|
||||
setEditorEmitter('componentPalette:pointerDown', { event, type })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="editor-sidebar-shell" :class="{ 'is-collapsed': collapsed }">
|
||||
<ToolRail :collapsed="collapsed" @toggle="onToggle" />
|
||||
|
||||
<section v-show="!collapsed" class="editor-sidebar-panel" data-editor-panel>
|
||||
<div class="sidebar-tab-bar">
|
||||
<button
|
||||
v-for="tab in EDITOR_SIDEBAR_TABS"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="sidebar-tab-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-tab-content">
|
||||
<LayersPanel v-if="activeTab === 'layers'" />
|
||||
<ComponentPalette
|
||||
v-else-if="activeTab === 'components'"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@pointer-down="onPointerDown"
|
||||
/>
|
||||
<TemplateLibrary
|
||||
v-else-if="activeTab === 'templates'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-sidebar-shell {
|
||||
position: relative;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.editor-sidebar-panel {
|
||||
width: var(--ed-sidebar-width);
|
||||
height: 100%;
|
||||
background: var(--ed-bg-surface);
|
||||
border-right: 1px solid var(--ed-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-sidebar-shell button,
|
||||
.editor-sidebar-shell input,
|
||||
.editor-sidebar-shell textarea,
|
||||
.editor-sidebar-shell select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.editor-sidebar-shell.is-collapsed .editor-sidebar-panel {
|
||||
width: 0;
|
||||
border-right: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-tab-bar {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid var(--ed-border);
|
||||
padding: 0 10px;
|
||||
background: var(--ed-bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-tab-item {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0 6px;
|
||||
font-size: var(--ed-font-sm);
|
||||
font-weight: var(--ed-font-weight-medium);
|
||||
color: var(--ed-text-tertiary);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color var(--ed-transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-tab-item:hover {
|
||||
color: var(--ed-text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-tab-item.active {
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.sidebar-tab-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
height: 2px;
|
||||
background: var(--ed-accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.sidebar-tab-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
export type EditorSidebarTabKey = 'layers' | 'components' | 'templates' | 'resources' | 'ai'
|
||||
|
||||
export interface EditorSidebarToolOption {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
export interface EditorSidebarToolItem {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
dividerBefore?: boolean
|
||||
options?: EditorSidebarToolOption[]
|
||||
}
|
||||
|
||||
export interface EditorSidebarTabItem {
|
||||
key: EditorSidebarTabKey
|
||||
label: string
|
||||
showNew?: boolean
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as EditorSidebar } from './editor-sidebar/index.vue'
|
||||
export { default as PropertyPanel } from './property-panel/index.vue'
|
||||
export { default as CanvasStage } from './stage/index.vue'
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 位号搜索视图 — 按设备位号搜索和分组展示图层
|
||||
*/
|
||||
import type { Layer } from '../../types'
|
||||
import { computed, ref } from 'vue'
|
||||
import { EdInput } from '@/components/editor/controls'
|
||||
import { useCanvasRuntimeInject } from '../../context'
|
||||
import { filterLayersByTag, groupLayersByTag, resolveComponentIcon, resolveLayerName } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
layers: Layer[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', layerId: string): void
|
||||
}>()
|
||||
|
||||
const { variableMap, ensureVariableCatalogLoaded } = useCanvasRuntimeInject()
|
||||
|
||||
// 确保变量目录已加载,否则无法将 moduleName 映射到 moduleLabel
|
||||
ensureVariableCatalogLoaded()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const filteredLayers = computed(() =>
|
||||
filterLayersByTag(props.layers, searchKeyword.value),
|
||||
)
|
||||
|
||||
// 通过 variableMap 将 moduleName(位号)映射到 moduleLabel(设备标签)
|
||||
function resolveTagLabel(tagNumber: string) {
|
||||
const vars = variableMap.value
|
||||
for (const v of Object.values(vars)) {
|
||||
if (v.moduleName === tagNumber) {
|
||||
return v.moduleLabel
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const groupedLayers = computed(() =>
|
||||
groupLayersByTag(filteredLayers.value, resolveTagLabel),
|
||||
)
|
||||
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleGroup(prefix: string) {
|
||||
if (collapsedGroups.value.has(prefix)) {
|
||||
collapsedGroups.value.delete(prefix)
|
||||
}
|
||||
else {
|
||||
collapsedGroups.value.add(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
function isGroupCollapsed(prefix: string) {
|
||||
return collapsedGroups.value.has(prefix)
|
||||
}
|
||||
|
||||
function getLayerDisplayName(layer: Layer, index: number): string {
|
||||
return resolveLayerName(layer, index)
|
||||
}
|
||||
|
||||
function onLayerClick(layerId: string) {
|
||||
emit('select', layerId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tag-number-view">
|
||||
<div class="layer-search-row">
|
||||
<EdInput v-model="searchKeyword" placeholder="搜索位号" />
|
||||
</div>
|
||||
<div class="layer-list">
|
||||
<template v-for="group in groupedLayers" :key="group.prefix">
|
||||
<!-- 分组头 -->
|
||||
<div class="layer-row is-group-row" @click="toggleGroup(group.prefix)">
|
||||
<div class="layer-left has-caret" style="padding-left: 10px;">
|
||||
<i
|
||||
class="fa-solid fa-caret-right group-caret"
|
||||
:class="{ 'is-collapsed': isGroupCollapsed(group.prefix) }"
|
||||
/>
|
||||
<button type="button" class="layer-icon-button">
|
||||
<i class="layer-glyph fa-regular fa-folder" />
|
||||
</button>
|
||||
<button type="button" class="layer-name-button">
|
||||
{{ group.prefix }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row-tail">
|
||||
<span class="group-count">{{ group.items.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 子项 -->
|
||||
<template v-if="!isGroupCollapsed(group.prefix)">
|
||||
<div
|
||||
v-for="(layer, index) in group.items"
|
||||
:key="layer.id"
|
||||
class="layer-row"
|
||||
@click="onLayerClick(layer.id)"
|
||||
>
|
||||
<div class="layer-left" style="padding-left: 32px;">
|
||||
<button type="button" class="layer-icon-button">
|
||||
<i class="layer-glyph" :class="resolveComponentIcon(layer.type)" />
|
||||
</button>
|
||||
<button type="button" class="layer-name-button">
|
||||
{{ getLayerDisplayName(layer, index) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="layer.tagNumber" class="layer-row-tail">
|
||||
<span class="tag-badge">{{ layer.tagNumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="groupedLayers.length === 0" class="tag-empty">
|
||||
无匹配位号
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './layer-row.scss';
|
||||
|
||||
.tag-number-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.layer-row-tail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--ed-text-tertiary);
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
font-size: 11px;
|
||||
color: var(--ed-text-tertiary);
|
||||
font-family: var(--ed-font-mono, monospace);
|
||||
}
|
||||
|
||||
.tag-empty {
|
||||
text-align: center;
|
||||
color: var(--ed-text-tertiary);
|
||||
font-size: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
// 共享的图层行样式 — 供 index.vue 和 TagNumberView.vue 复用
|
||||
|
||||
.layer-search-row {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--ed-border);
|
||||
}
|
||||
|
||||
.layer-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 2px 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.layer-list:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
height: 30px;
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.layer-row:not(.is-group-row):not(.active):not(.active-group):not(.is-renaming):hover {
|
||||
background: #ffffff;
|
||||
box-shadow: inset 0 0 0 1px var(--ed-accent);
|
||||
}
|
||||
|
||||
.layer-row.is-group-row {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.layer-row.is-group-row:hover {
|
||||
background: var(--ed-bg-hover);
|
||||
}
|
||||
|
||||
.layer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layer-left.has-caret {
|
||||
padding-left: 10px !important;
|
||||
}
|
||||
|
||||
.layer-icon-button,
|
||||
.layer-name-button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.layer-icon-button {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.layer-glyph {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-tertiary);
|
||||
}
|
||||
|
||||
.layer-name-button {
|
||||
margin-left: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
color: var(--ed-text-primary);
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-caret {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-tertiary);
|
||||
transition: transform 0.15s ease;
|
||||
transform: rotate(90deg);
|
||||
margin-left: -3px;
|
||||
margin-right: 4px;
|
||||
|
||||
&.is-collapsed {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type PaletteLayerKind = 'group' | 'component'
|
||||
export type PaletteLayerVisibilityState = 'visible' | 'hidden' | 'mixed'
|
||||
export type PaletteLayerLockState = 'locked' | 'unlocked' | 'mixed'
|
||||
|
||||
export interface PaletteLayerItem {
|
||||
id: string
|
||||
name: string
|
||||
kind?: PaletteLayerKind
|
||||
groupId?: string
|
||||
componentId?: string
|
||||
icon?: string
|
||||
selectable?: boolean
|
||||
level?: number
|
||||
locked?: boolean
|
||||
visible?: boolean
|
||||
visibilityState?: PaletteLayerVisibilityState
|
||||
lockState?: PaletteLayerLockState
|
||||
hasLockedDescendants?: boolean
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Layer } from '../../types'
|
||||
import type { PaletteLayerItem } from './types'
|
||||
import { asRecord, getLayerGroupMeta } from '../../grouping'
|
||||
|
||||
function readStringValue(source: Record<string, unknown> | null, keys: string[]): string | null {
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveLayerName(layer: Layer, index: number): string {
|
||||
const layerRecord = asRecord(layer as unknown)
|
||||
const configRecord = asRecord(layer.config)
|
||||
|
||||
const directName = readStringValue(layerRecord, ['name', 'title', 'label'])
|
||||
|| readStringValue(configRecord, ['name', 'title', 'label'])
|
||||
if (directName) {
|
||||
return directName
|
||||
}
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
rect: '矩形',
|
||||
number: '数值',
|
||||
text: '文本',
|
||||
bar: '棒图',
|
||||
progress: '进度条',
|
||||
button: '按钮',
|
||||
custom: '组件',
|
||||
}
|
||||
|
||||
return `${typeLabel[layer.type] || layer.type}-${index + 1}`
|
||||
}
|
||||
|
||||
export function resolveComponentIcon(type: string) {
|
||||
const iconMap: Record<string, string> = {
|
||||
rect: 'fa-regular fa-square',
|
||||
number: 'fa-solid fa-hashtag',
|
||||
text: 'fa-solid fa-font',
|
||||
bar: 'fa-solid fa-mattress-pillow',
|
||||
progress: 'fa-solid fa-gauge',
|
||||
button: 'fa-regular fa-hand-pointer',
|
||||
custom: 'fa-regular fa-square',
|
||||
}
|
||||
return iconMap[type] || 'fa-regular fa-square'
|
||||
}
|
||||
|
||||
export const resolveGroupMeta = getLayerGroupMeta
|
||||
|
||||
/** 按位号标签分组图层 */
|
||||
export function groupLayersByTag(
|
||||
layers: Layer[],
|
||||
labelResolver?: (tagNumber: string) => string | undefined,
|
||||
): { prefix: string, items: Layer[] }[] {
|
||||
const groups = new Map<string, Layer[]>()
|
||||
const unassigned: Layer[] = []
|
||||
|
||||
for (const layer of layers) {
|
||||
const tag = layer.tagNumber
|
||||
if (!tag) {
|
||||
unassigned.push(layer)
|
||||
continue
|
||||
}
|
||||
const groupKey = labelResolver?.(tag) || tag
|
||||
if (!groups.has(groupKey))
|
||||
groups.set(groupKey, [])
|
||||
groups.get(groupKey)!.push(layer)
|
||||
}
|
||||
|
||||
const sortedEntries = [...groups.entries()]
|
||||
sortedEntries.sort(([a], [b]) => a.localeCompare(b))
|
||||
const result = sortedEntries.map(([prefix, items]) => ({ prefix, items }))
|
||||
|
||||
if (unassigned.length > 0) {
|
||||
result.push({ prefix: '未分配', items: unassigned })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** 按位号关键词过滤图层 */
|
||||
export function filterLayersByTag(layers: Layer[], keyword: string): Layer[] {
|
||||
if (!keyword.trim())
|
||||
return layers
|
||||
const kw = keyword.toLowerCase()
|
||||
return layers.filter(l => l.tagNumber?.toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
export function filterLayers(layers: PaletteLayerItem[], keyword: string): PaletteLayerItem[] {
|
||||
const normalized = keyword.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return layers
|
||||
}
|
||||
|
||||
const result: PaletteLayerItem[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < layers.length) {
|
||||
const current = layers[index]
|
||||
const currentLevel = current.level || 0
|
||||
|
||||
if (current.kind !== 'group') {
|
||||
if (current.name.toLowerCase().includes(normalized)) {
|
||||
result.push(current)
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const children: PaletteLayerItem[] = []
|
||||
let cursor = index + 1
|
||||
while (cursor < layers.length) {
|
||||
const next = layers[cursor]
|
||||
if ((next.level || 0) <= currentLevel) {
|
||||
break
|
||||
}
|
||||
children.push(next)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
const matchedChildren = children.filter(child => child.name.toLowerCase().includes(normalized))
|
||||
const groupMatched = current.name.toLowerCase().includes(normalized)
|
||||
|
||||
if (groupMatched || matchedChildren.length > 0) {
|
||||
result.push(current)
|
||||
result.push(...(groupMatched ? children : matchedChildren))
|
||||
}
|
||||
|
||||
index = cursor
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { computed, onBeforeUnmount, shallowRef } from 'vue'
|
||||
import { useCanvasState } from '../../context/state'
|
||||
import { setEditorEmitter } from '../../emitter'
|
||||
|
||||
// ── 常量 ──
|
||||
const MINIMAP_WIDTH = 160
|
||||
const MINIMAP_HEIGHT = 120
|
||||
const MINIMAP_PADDING = 8
|
||||
|
||||
// ── 状态 ──
|
||||
const { canvasView, layerList, activeCanvasThumbnail } = useCanvasState()
|
||||
const collapsed = useSessionStorage('DCS_MINIMAP_COLLAPSED', false)
|
||||
const minimapRef = shallowRef<HTMLDivElement | null>(null)
|
||||
const isDragging = shallowRef(false)
|
||||
|
||||
// ── 可见图层 ──
|
||||
const visibleLayers = computed(() =>
|
||||
layerList.value.filter(layer => layer.config?.visible !== false),
|
||||
)
|
||||
|
||||
// ── 内容边界(图层 + 画布底图区域的并集) ──
|
||||
const contentBounds = computed(() => {
|
||||
const iw = canvasView.value.imageWidth
|
||||
const ih = canvasView.value.imageHeight
|
||||
|
||||
// 起始边界 = 底图区域
|
||||
let minX = 0
|
||||
let minY = 0
|
||||
let maxX = iw || 1920
|
||||
let maxY = ih || 1080
|
||||
|
||||
// 将所有图层纳入边界
|
||||
for (const layer of visibleLayers.value) {
|
||||
minX = Math.min(minX, layer.x)
|
||||
minY = Math.min(minY, layer.y)
|
||||
maxX = Math.max(maxX, layer.x + layer.width)
|
||||
maxY = Math.max(maxY, layer.y + layer.height)
|
||||
}
|
||||
|
||||
const width = maxX - minX || 1
|
||||
const height = maxY - minY || 1
|
||||
|
||||
return { minX, minY, maxX, maxY, width, height }
|
||||
})
|
||||
|
||||
// ── 缩放因子(将内容区域适配到小地图尺寸) ──
|
||||
const minimapScale = computed(() => {
|
||||
const { width, height } = contentBounds.value
|
||||
const innerW = MINIMAP_WIDTH - MINIMAP_PADDING * 2
|
||||
const innerH = MINIMAP_HEIGHT - MINIMAP_PADDING * 2
|
||||
return Math.min(innerW / width, innerH / height)
|
||||
})
|
||||
|
||||
// ── 图层矩形样式 ──
|
||||
function getLayerRectStyle(layer: { x: number, y: number, width: number, height: number, style?: Record<string, any>, config?: Record<string, any>, type: string }): CSSProperties {
|
||||
const scale = minimapScale.value
|
||||
const { minX, minY } = contentBounds.value
|
||||
|
||||
const x = (layer.x - minX) * scale + MINIMAP_PADDING
|
||||
const y = (layer.y - minY) * scale + MINIMAP_PADDING
|
||||
const w = Math.max(layer.width * scale, 1)
|
||||
const h = Math.max(layer.height * scale, 1)
|
||||
|
||||
// 尝试从图层样式或配置中提取颜色
|
||||
const fillColor
|
||||
= layer.style?.fill?.color
|
||||
|| layer.config?.fillColor
|
||||
|| layer.style?.background
|
||||
|| getDefaultColorByType(layer.type)
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
background: fillColor,
|
||||
borderRadius: '0.5px',
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultColorByType(type: string): string {
|
||||
switch (type) {
|
||||
case 'rect':
|
||||
return 'rgba(22, 119, 255, 0.5)'
|
||||
case 'text':
|
||||
return 'rgba(100, 100, 100, 0.5)'
|
||||
case 'number':
|
||||
return 'rgba(10, 192, 94, 0.5)'
|
||||
case 'bar':
|
||||
return 'rgba(250, 173, 20, 0.5)'
|
||||
case 'button':
|
||||
return 'rgba(22, 119, 255, 0.6)'
|
||||
default:
|
||||
return 'rgba(22, 119, 255, 0.4)'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 底图区域样式 ──
|
||||
const baseLayerStyle = computed<CSSProperties>(() => {
|
||||
const scale = minimapScale.value
|
||||
const { minX, minY } = contentBounds.value
|
||||
const iw = canvasView.value.imageWidth || 1920
|
||||
const ih = canvasView.value.imageHeight || 1080
|
||||
|
||||
const bg = canvasView.value.background || '#FFFFFF'
|
||||
const thumbnail = activeCanvasThumbnail.value
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: `${(0 - minX) * scale + MINIMAP_PADDING}px`,
|
||||
top: `${(0 - minY) * scale + MINIMAP_PADDING}px`,
|
||||
width: `${iw * scale}px`,
|
||||
height: `${ih * scale}px`,
|
||||
background: thumbnail ? `url(${thumbnail}) center / cover no-repeat` : bg,
|
||||
border: '1px solid var(--ed-border)',
|
||||
}
|
||||
})
|
||||
|
||||
// ── 视口指示器样式 ──
|
||||
const viewportStyle = computed<CSSProperties | null>(() => {
|
||||
const { scale: viewScale, offsetX, offsetY, canvasWidth, canvasHeight } = canvasView.value
|
||||
if (!canvasWidth || !canvasHeight || viewScale <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mScale = minimapScale.value
|
||||
const { minX, minY } = contentBounds.value
|
||||
|
||||
// 视口在画布坐标中的位置和大小
|
||||
const vpX = -offsetX / viewScale
|
||||
const vpY = -offsetY / viewScale
|
||||
const vpW = canvasWidth / viewScale
|
||||
const vpH = canvasHeight / viewScale
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: `${(vpX - minX) * mScale + MINIMAP_PADDING}px`,
|
||||
top: `${(vpY - minY) * mScale + MINIMAP_PADDING}px`,
|
||||
width: `${vpW * mScale}px`,
|
||||
height: `${vpH * mScale}px`,
|
||||
border: '2px solid var(--ed-accent)',
|
||||
background: 'rgba(22, 119, 255, 0.08)',
|
||||
borderRadius: '1px',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
})
|
||||
|
||||
// ── 点击/拖拽导航 ──
|
||||
function screenToCanvasCoords(clientX: number, clientY: number) {
|
||||
if (!minimapRef.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = minimapRef.value.getBoundingClientRect()
|
||||
const mx = clientX - rect.left - MINIMAP_PADDING
|
||||
const my = clientY - rect.top - MINIMAP_PADDING
|
||||
|
||||
const mScale = minimapScale.value
|
||||
const { minX, minY } = contentBounds.value
|
||||
|
||||
// 小地图坐标 → 画布坐标
|
||||
const canvasX = mx / mScale + minX
|
||||
const canvasY = my / mScale + minY
|
||||
|
||||
return { canvasX, canvasY }
|
||||
}
|
||||
|
||||
function panViewportTo(clientX: number, clientY: number) {
|
||||
const coords = screenToCanvasCoords(clientX, clientY)
|
||||
if (!coords) {
|
||||
return
|
||||
}
|
||||
|
||||
const { scale: viewScale, offsetX, offsetY, canvasWidth, canvasHeight } = canvasView.value
|
||||
|
||||
// 将视口中心移到点击位置,通过 viewport:panBy 事件通知 canvas-stage 同步重绘底图。
|
||||
const vpW = canvasWidth / viewScale
|
||||
const vpH = canvasHeight / viewScale
|
||||
|
||||
const targetOffsetX = -(coords.canvasX - vpW / 2) * viewScale
|
||||
const targetOffsetY = -(coords.canvasY - vpH / 2) * viewScale
|
||||
|
||||
setEditorEmitter('viewport:panBy', {
|
||||
deltaX: targetOffsetX - offsetX,
|
||||
deltaY: targetOffsetY - offsetY,
|
||||
})
|
||||
}
|
||||
|
||||
function onMinimapPointerDown(event: PointerEvent) {
|
||||
// 阻止事件冒泡到画布
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
|
||||
isDragging.value = true
|
||||
panViewportTo(event.clientX, event.clientY)
|
||||
|
||||
document.addEventListener('pointermove', onDocumentPointerMove)
|
||||
document.addEventListener('pointerup', onDocumentPointerUp)
|
||||
}
|
||||
|
||||
function onDocumentPointerMove(event: PointerEvent) {
|
||||
if (!isDragging.value) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
panViewportTo(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
function onDocumentPointerUp() {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('pointermove', onDocumentPointerMove)
|
||||
document.removeEventListener('pointerup', onDocumentPointerUp)
|
||||
}
|
||||
|
||||
function toggleCollapsed() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointermove', onDocumentPointerMove)
|
||||
document.removeEventListener('pointerup', onDocumentPointerUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="minimap-container" :class="{ 'is-collapsed': collapsed }">
|
||||
<button
|
||||
class="minimap-toggle"
|
||||
:title="collapsed ? '显示小地图' : '隐藏小地图'"
|
||||
@click.stop="toggleCollapsed"
|
||||
>
|
||||
<svg
|
||||
v-if="collapsed"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="!collapsed"
|
||||
ref="minimapRef"
|
||||
class="minimap-panel"
|
||||
@pointerdown="onMinimapPointerDown"
|
||||
>
|
||||
<!-- 底图区域 -->
|
||||
<div class="minimap-base-layer" :style="baseLayerStyle" />
|
||||
|
||||
<!-- 图层矩形 -->
|
||||
<div
|
||||
v-for="layer in visibleLayers"
|
||||
:key="layer.id"
|
||||
class="minimap-layer-rect"
|
||||
:style="getLayerRectStyle(layer)"
|
||||
/>
|
||||
|
||||
<!-- 视口指示器 -->
|
||||
<div
|
||||
v-if="viewportStyle"
|
||||
class="minimap-viewport"
|
||||
:style="viewportStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.minimap-container {
|
||||
position: absolute;
|
||||
right: var(--ed-space-5);
|
||||
bottom: var(--ed-space-5);
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--ed-space-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimap-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ed-border);
|
||||
border-radius: var(--ed-radius-sm);
|
||||
background: var(--ed-bg-raised);
|
||||
color: var(--ed-text-secondary);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: var(--ed-shadow-sm);
|
||||
transition: background var(--ed-transition-fast), color var(--ed-transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--ed-bg-hover);
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--ed-bg-active);
|
||||
}
|
||||
}
|
||||
|
||||
.minimap-panel {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
background: var(--ed-bg-surface);
|
||||
border: 1px solid var(--ed-border);
|
||||
border-radius: var(--ed-radius-md);
|
||||
box-shadow: var(--ed-shadow-md);
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.minimap-base-layer {
|
||||
pointer-events: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.minimap-layer-rect {
|
||||
pointer-events: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.minimap-viewport {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimap-container.is-collapsed .minimap-panel {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { inject, provide } from 'vue'
|
||||
|
||||
export interface PropertyContext {
|
||||
/** 更新属性字段(自动处理分组/多选) */
|
||||
updateField: (field: string, value: unknown) => void
|
||||
/** 获取字段状态(自动处理分组混合值) */
|
||||
getFieldState: (fieldKey: string) => { value: unknown, mixed: boolean }
|
||||
}
|
||||
|
||||
// 使用字符串 key 确保 HMR 热更新时 provide/inject 不会断链
|
||||
const KEY = 'ed-property-context'
|
||||
|
||||
export function providePropertyContext(ctx: PropertyContext) {
|
||||
provide(KEY, ctx)
|
||||
}
|
||||
|
||||
export function usePropertyContext(): PropertyContext {
|
||||
const ctx = inject<PropertyContext>(KEY)
|
||||
if (!ctx) {
|
||||
console.warn('[usePropertyContext] inject 失败,请确保在 PropertyPanel 内使用')
|
||||
return {
|
||||
updateField: () => {},
|
||||
getFieldState: () => ({ value: undefined, mixed: false }),
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import { MAX_CANVAS_SIZE, MIN_CANVAS_HEIGHT, MIN_CANVAS_WIDTH } from '@/constants'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { listenEditorEmitter, setEditorEmitter } from '../../../emitter'
|
||||
import { buildGroupRectUpdates } from '../../../grouping'
|
||||
import { clamp } from '../../../utils'
|
||||
|
||||
export function usePropertyPanel() {
|
||||
const { canvasId, canvasView, selectedLayerId, selectedLayerIds, selectedLayer, selectedGroup, isBaseLayerActive } = useCanvasState()
|
||||
|
||||
const panelRef = shallowRef<HTMLDivElement | null>(null)
|
||||
const visible = ref(false)
|
||||
const interactionSuspended = ref(false)
|
||||
const restoreVisibleAfterInteraction = ref(false)
|
||||
const shouldRenderPanel = ref(Boolean(canvasId.value))
|
||||
|
||||
function normalizeLockedSizeByWidth(width: number, ratio: number) {
|
||||
let nextWidth = clamp(Math.round(width), MIN_CANVAS_WIDTH, MAX_CANVAS_SIZE)
|
||||
let nextHeight = Math.round(nextWidth / ratio)
|
||||
|
||||
if (nextHeight < MIN_CANVAS_HEIGHT) {
|
||||
nextHeight = MIN_CANVAS_HEIGHT
|
||||
nextWidth = Math.round(nextHeight * ratio)
|
||||
}
|
||||
if (nextWidth < MIN_CANVAS_WIDTH) {
|
||||
nextWidth = MIN_CANVAS_WIDTH
|
||||
nextHeight = Math.round(nextWidth / ratio)
|
||||
}
|
||||
if (nextHeight > MAX_CANVAS_SIZE) {
|
||||
nextHeight = MAX_CANVAS_SIZE
|
||||
nextWidth = Math.round(nextHeight * ratio)
|
||||
}
|
||||
if (nextWidth > MAX_CANVAS_SIZE) {
|
||||
nextWidth = MAX_CANVAS_SIZE
|
||||
nextHeight = Math.round(nextWidth / ratio)
|
||||
}
|
||||
|
||||
return {
|
||||
nextWidth: clamp(nextWidth, MIN_CANVAS_WIDTH, MAX_CANVAS_SIZE),
|
||||
nextHeight: clamp(nextHeight, MIN_CANVAS_HEIGHT, MAX_CANVAS_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLockedSizeByHeight(height: number, ratio: number) {
|
||||
let nextHeight = clamp(Math.round(height), MIN_CANVAS_HEIGHT, MAX_CANVAS_SIZE)
|
||||
let nextWidth = Math.round(nextHeight * ratio)
|
||||
|
||||
if (nextWidth < MIN_CANVAS_WIDTH) {
|
||||
nextWidth = MIN_CANVAS_WIDTH
|
||||
nextHeight = Math.round(nextWidth / ratio)
|
||||
}
|
||||
if (nextHeight < MIN_CANVAS_HEIGHT) {
|
||||
nextHeight = MIN_CANVAS_HEIGHT
|
||||
nextWidth = Math.round(nextHeight * ratio)
|
||||
}
|
||||
if (nextWidth > MAX_CANVAS_SIZE) {
|
||||
nextWidth = MAX_CANVAS_SIZE
|
||||
nextHeight = Math.round(nextWidth / ratio)
|
||||
}
|
||||
if (nextHeight > MAX_CANVAS_SIZE) {
|
||||
nextHeight = MAX_CANVAS_SIZE
|
||||
nextWidth = Math.round(nextHeight * ratio)
|
||||
}
|
||||
|
||||
return {
|
||||
nextWidth: clamp(nextWidth, MIN_CANVAS_WIDTH, MAX_CANVAS_SIZE),
|
||||
nextHeight: clamp(nextHeight, MIN_CANVAS_HEIGHT, MAX_CANVAS_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
function onPropertyUpdate(payload: { field: string, value: any }) {
|
||||
const layer = selectedLayer.value
|
||||
const group = selectedGroup.value
|
||||
const { field, value } = payload
|
||||
|
||||
// 多选批量编辑(非分组)
|
||||
if (!layer && !group && selectedLayerIds.value.length > 1) {
|
||||
if (['x', 'y', 'width', 'height'].includes(field)) {
|
||||
// 位置/尺寸批量编辑暂不支持(各图层位置不同,需要分组操作)
|
||||
return
|
||||
}
|
||||
setEditorEmitter('layer:updateFields', {
|
||||
ids: selectedLayerIds.value,
|
||||
field,
|
||||
value,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!layer && !group) {
|
||||
if (field === 'view.background') {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
canvasView.value.background = value
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (field === 'view.backgroundLockAspectRatio') {
|
||||
canvasView.value.backgroundLockAspectRatio = Boolean(value)
|
||||
return
|
||||
}
|
||||
|
||||
if (field === 'view.imageWidth' || field === 'view.imageHeight') {
|
||||
const numericValue = Number(value)
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentWidth = Math.max(MIN_CANVAS_WIDTH, Math.round(canvasView.value.imageWidth || MIN_CANVAS_WIDTH))
|
||||
const currentHeight = Math.max(MIN_CANVAS_HEIGHT, Math.round(canvasView.value.imageHeight || MIN_CANVAS_HEIGHT))
|
||||
let nextWidth = currentWidth
|
||||
let nextHeight = currentHeight
|
||||
|
||||
if (canvasView.value.backgroundLockAspectRatio && currentWidth > 0 && currentHeight > 0) {
|
||||
const ratio = currentWidth / currentHeight
|
||||
if (field === 'view.imageWidth') {
|
||||
const normalized = normalizeLockedSizeByWidth(numericValue, ratio)
|
||||
nextWidth = normalized.nextWidth
|
||||
nextHeight = normalized.nextHeight
|
||||
}
|
||||
else {
|
||||
const normalized = normalizeLockedSizeByHeight(numericValue, ratio)
|
||||
nextWidth = normalized.nextWidth
|
||||
nextHeight = normalized.nextHeight
|
||||
}
|
||||
}
|
||||
else if (field === 'view.imageWidth') {
|
||||
nextWidth = clamp(Math.round(numericValue), MIN_CANVAS_WIDTH, MAX_CANVAS_SIZE)
|
||||
}
|
||||
else {
|
||||
nextHeight = clamp(Math.round(numericValue), MIN_CANVAS_HEIGHT, MAX_CANVAS_SIZE)
|
||||
}
|
||||
|
||||
setEditorEmitter('baseLayer:setSize', { width: nextWidth, height: nextHeight })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const hasCanvasSize = canvasView.value.imageWidth > 0 && canvasView.value.imageHeight > 0
|
||||
|
||||
if (group) {
|
||||
if (['x', 'y', 'width', 'height'].includes(field)) {
|
||||
if (!hasCanvasSize) {
|
||||
return
|
||||
}
|
||||
const minSize = 24
|
||||
let nextX = group.x
|
||||
let nextY = group.y
|
||||
let nextWidth = group.width
|
||||
let nextHeight = group.height
|
||||
|
||||
if (field === 'x') {
|
||||
nextX = clamp(value, 0, Math.max(0, canvasView.value.imageWidth - nextWidth))
|
||||
}
|
||||
if (field === 'y') {
|
||||
nextY = clamp(value, 0, Math.max(0, canvasView.value.imageHeight - nextHeight))
|
||||
}
|
||||
if (field === 'width') {
|
||||
nextWidth = clamp(value, minSize, canvasView.value.imageWidth)
|
||||
nextX = clamp(nextX, 0, Math.max(0, canvasView.value.imageWidth - nextWidth))
|
||||
}
|
||||
if (field === 'height') {
|
||||
nextHeight = clamp(value, minSize, canvasView.value.imageHeight)
|
||||
nextY = clamp(nextY, 0, Math.max(0, canvasView.value.imageHeight - nextHeight))
|
||||
}
|
||||
|
||||
setEditorEmitter('layer:updateRects', buildGroupRectUpdates(
|
||||
group.layers,
|
||||
group,
|
||||
{
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
},
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
setEditorEmitter('layer:updateFields', {
|
||||
ids: group.layerIds,
|
||||
field,
|
||||
value,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!layer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (['x', 'y', 'width', 'height'].includes(field)) {
|
||||
if (!hasCanvasSize) {
|
||||
return
|
||||
}
|
||||
const minSize = 24
|
||||
let nextX = layer.x
|
||||
let nextY = layer.y
|
||||
let nextWidth = layer.width
|
||||
let nextHeight = layer.height
|
||||
|
||||
if (field === 'x') {
|
||||
nextX = clamp(value, 0, Math.max(0, canvasView.value.imageWidth - nextWidth))
|
||||
}
|
||||
if (field === 'y') {
|
||||
nextY = clamp(value, 0, Math.max(0, canvasView.value.imageHeight - nextHeight))
|
||||
}
|
||||
if (field === 'width') {
|
||||
nextWidth = clamp(value, minSize, canvasView.value.imageWidth)
|
||||
nextX = clamp(nextX, 0, Math.max(0, canvasView.value.imageWidth - nextWidth))
|
||||
}
|
||||
if (field === 'height') {
|
||||
nextHeight = clamp(value, minSize, canvasView.value.imageHeight)
|
||||
nextY = clamp(nextY, 0, Math.max(0, canvasView.value.imageHeight - nextHeight))
|
||||
}
|
||||
|
||||
setEditorEmitter('layer:updateRect', {
|
||||
id: layer.id,
|
||||
nextX,
|
||||
nextY,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setEditorEmitter('layer:updateField', {
|
||||
id: layer.id,
|
||||
field,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
function canShowPanel() {
|
||||
return Boolean(canvasId.value)
|
||||
}
|
||||
|
||||
function suspendPanel() {
|
||||
if (!interactionSuspended.value) {
|
||||
restoreVisibleAfterInteraction.value = visible.value
|
||||
}
|
||||
interactionSuspended.value = true
|
||||
visible.value = false
|
||||
}
|
||||
listenEditorEmitter('propertyPanel:suspend', suspendPanel)
|
||||
|
||||
function resumePanel() {
|
||||
if (!interactionSuspended.value) {
|
||||
return
|
||||
}
|
||||
|
||||
interactionSuspended.value = false
|
||||
const shouldRestore = restoreVisibleAfterInteraction.value
|
||||
restoreVisibleAfterInteraction.value = false
|
||||
|
||||
if (shouldRestore && canShowPanel()) {
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
listenEditorEmitter('propertyPanel:resume', resumePanel)
|
||||
|
||||
function closePanel() {
|
||||
restoreVisibleAfterInteraction.value = false
|
||||
visible.value = canShowPanel()
|
||||
}
|
||||
listenEditorEmitter('propertyPanel:closePanel', closePanel)
|
||||
|
||||
function openPanel() {
|
||||
if (interactionSuspended.value) {
|
||||
restoreVisibleAfterInteraction.value = canShowPanel()
|
||||
return
|
||||
}
|
||||
|
||||
visible.value = canShowPanel()
|
||||
}
|
||||
listenEditorEmitter('propertyPanel:openPanel', openPanel)
|
||||
|
||||
watch(
|
||||
() => [canvasId.value, selectedLayerId.value, selectedLayerIds.value.length, selectedGroup.value?.groupId ?? '', isBaseLayerActive.value],
|
||||
() => {
|
||||
shouldRenderPanel.value = Boolean(canvasId.value)
|
||||
if (!interactionSuspended.value) {
|
||||
visible.value = canShowPanel()
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
shouldRenderPanel.value = Boolean(canvasId.value)
|
||||
visible.value = canShowPanel()
|
||||
|
||||
return {
|
||||
panelRef,
|
||||
visible,
|
||||
shouldRenderPanel,
|
||||
onPropertyUpdate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
interface PanelPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ed:property-panel-dock'
|
||||
const DEFAULT_POSITION: PanelPosition = { x: -1, y: -1 }
|
||||
const PANEL_WIDTH = 240
|
||||
const PANEL_MIN_VISIBLE = 48
|
||||
|
||||
function loadPersistedState() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { isDocked: boolean, position: PanelPosition }
|
||||
if (typeof parsed.isDocked !== 'boolean') {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function persistState(isDocked: boolean, position: PanelPosition) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ isDocked, position }))
|
||||
}
|
||||
catch {
|
||||
// sessionStorage 不可用时静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
export function usePropertyPanelDock() {
|
||||
const persisted = loadPersistedState()
|
||||
|
||||
const isDocked = ref(persisted?.isDocked ?? true)
|
||||
const position = reactive<PanelPosition>(
|
||||
persisted?.position ?? { ...DEFAULT_POSITION },
|
||||
)
|
||||
|
||||
// 拖拽状态
|
||||
const isDragging = ref(false)
|
||||
let dragStartX = 0
|
||||
let dragStartY = 0
|
||||
let dragStartPosX = 0
|
||||
let dragStartPosY = 0
|
||||
|
||||
// 初始化浮动位置(未设置过时居中偏右)
|
||||
function initDefaultPosition() {
|
||||
if (position.x < 0 || position.y < 0) {
|
||||
position.x = Math.max(0, window.innerWidth - PANEL_WIDTH - 60)
|
||||
position.y = 80
|
||||
}
|
||||
}
|
||||
|
||||
// 将面板约束在视口内
|
||||
function clampToViewport() {
|
||||
const maxX = window.innerWidth - PANEL_MIN_VISIBLE
|
||||
const maxY = window.innerHeight - PANEL_MIN_VISIBLE
|
||||
position.x = Math.max(0, Math.min(position.x, maxX))
|
||||
position.y = Math.max(0, Math.min(position.y, maxY))
|
||||
}
|
||||
|
||||
function toggleDock() {
|
||||
isDocked.value = !isDocked.value
|
||||
if (!isDocked.value) {
|
||||
initDefaultPosition()
|
||||
clampToViewport()
|
||||
}
|
||||
persistState(isDocked.value, position)
|
||||
}
|
||||
|
||||
// 拖拽处理
|
||||
function onDragStart(event: MouseEvent) {
|
||||
if (isDocked.value) {
|
||||
return
|
||||
}
|
||||
// 仅左键触发
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
isDragging.value = true
|
||||
dragStartX = event.clientX
|
||||
dragStartY = event.clientY
|
||||
dragStartPosX = position.x
|
||||
dragStartPosY = position.y
|
||||
|
||||
document.addEventListener('mousemove', onDragMove)
|
||||
document.addEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
function onDragMove(event: MouseEvent) {
|
||||
if (!isDragging.value) {
|
||||
return
|
||||
}
|
||||
const dx = event.clientX - dragStartX
|
||||
const dy = event.clientY - dragStartY
|
||||
position.x = dragStartPosX + dx
|
||||
position.y = dragStartPosY + dy
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
if (!isDragging.value) {
|
||||
return
|
||||
}
|
||||
isDragging.value = false
|
||||
clampToViewport()
|
||||
persistState(isDocked.value, position)
|
||||
|
||||
document.removeEventListener('mousemove', onDragMove)
|
||||
document.removeEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
// 浮动时的样式
|
||||
const floatingStyle = computed(() => {
|
||||
if (isDocked.value) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
zIndex: 1000,
|
||||
height: 'auto',
|
||||
maxHeight: '80vh',
|
||||
width: `${PANEL_WIDTH}px`,
|
||||
boxShadow: 'var(--ed-shadow-lg)',
|
||||
borderRadius: 'var(--ed-radius-lg)',
|
||||
transition: isDragging.value ? 'none' : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
// 窗口 resize 时约束面板位置
|
||||
function onWindowResize() {
|
||||
if (!isDocked.value) {
|
||||
clampToViewport()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
if (!isDocked.value) {
|
||||
initDefaultPosition()
|
||||
clampToViewport()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
document.removeEventListener('mousemove', onDragMove)
|
||||
document.removeEventListener('mouseup', onDragEnd)
|
||||
})
|
||||
|
||||
// 状态变化时持久化
|
||||
watch(
|
||||
() => isDocked.value,
|
||||
() => persistState(isDocked.value, position),
|
||||
)
|
||||
|
||||
return {
|
||||
isDocked,
|
||||
isDragging,
|
||||
position,
|
||||
floatingStyle,
|
||||
toggleDock,
|
||||
onDragStart,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import BarConfig from '../sections/BarConfig.vue'
|
||||
import ButtonConfig from '../sections/ButtonConfig.vue'
|
||||
import CanvasSwitcherConfig from '../sections/CanvasSwitcherConfig.vue'
|
||||
import EventEditor from '../sections/EventEditor.vue'
|
||||
import PidControllerConfig from '../sections/PidControllerConfig.vue'
|
||||
import SectionFill from '../sections/SectionFill.vue'
|
||||
import SectionGeometry from '../sections/SectionGeometry.vue'
|
||||
import SectionNumberFormat from '../sections/SectionNumberFormat.vue'
|
||||
import SectionShadow from '../sections/SectionShadow.vue'
|
||||
import SectionStroke from '../sections/SectionStroke.vue'
|
||||
import SectionText from '../sections/SectionText.vue'
|
||||
import SectionTextContent from '../sections/SectionTextContent.vue'
|
||||
import ValveControllerConfig from '../sections/ValveControllerConfig.vue'
|
||||
|
||||
export interface SectionEntry {
|
||||
key: string
|
||||
component: Component
|
||||
defaultCollapsed?: boolean
|
||||
}
|
||||
|
||||
const SECTION_MAP: Record<string, SectionEntry[]> = {
|
||||
rect: [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'fill', component: SectionFill },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
{ key: 'shadow', component: SectionShadow, defaultCollapsed: true },
|
||||
|
||||
{ key: 'event', component: EventEditor, defaultCollapsed: true },
|
||||
],
|
||||
number: [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'numberFormat', component: SectionNumberFormat },
|
||||
{ key: 'text', component: SectionText },
|
||||
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
{ key: 'shadow', component: SectionShadow, defaultCollapsed: true },
|
||||
{ key: 'event', component: EventEditor, defaultCollapsed: true },
|
||||
],
|
||||
text: [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'textContent', component: SectionTextContent },
|
||||
{ key: 'text', component: SectionText },
|
||||
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
{ key: 'shadow', component: SectionShadow, defaultCollapsed: true },
|
||||
{ key: 'event', component: EventEditor, defaultCollapsed: true },
|
||||
],
|
||||
bar: [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'barConfig', component: BarConfig },
|
||||
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
{ key: 'event', component: EventEditor, defaultCollapsed: true },
|
||||
],
|
||||
button: [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'buttonConfig', component: ButtonConfig },
|
||||
{ key: 'text', component: SectionText },
|
||||
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
{ key: 'event', component: EventEditor },
|
||||
],
|
||||
pidController: [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'pidControllerConfig', component: PidControllerConfig },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
],
|
||||
valveController: [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'valveControllerConfig', component: ValveControllerConfig },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
],
|
||||
canvasSwitcher: [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'canvasSwitcherConfig', component: CanvasSwitcherConfig },
|
||||
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
{ key: 'event', component: EventEditor, defaultCollapsed: true },
|
||||
],
|
||||
}
|
||||
|
||||
const FALLBACK_SECTIONS: SectionEntry[] = [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'fill', component: SectionFill },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
{ key: 'shadow', component: SectionShadow, defaultCollapsed: true },
|
||||
{ key: 'event', component: EventEditor, defaultCollapsed: true },
|
||||
]
|
||||
|
||||
// 多选不同类型时使用公共 Section(geometry + fill + stroke)
|
||||
const MULTI_SELECT_SECTIONS: SectionEntry[] = [
|
||||
{ key: 'geometry', component: SectionGeometry },
|
||||
{ key: 'fill', component: SectionFill },
|
||||
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
|
||||
]
|
||||
|
||||
export function usePropertySections() {
|
||||
const { selectedLayer, selectedGroup, selectedLayerIds } = useCanvasState()
|
||||
|
||||
const sections = computed<SectionEntry[]>(() => {
|
||||
// 多选(非分组)时显示公共属性面板
|
||||
if (!selectedLayer.value && !selectedGroup.value && selectedLayerIds.value.length > 1) {
|
||||
return MULTI_SELECT_SECTIONS
|
||||
}
|
||||
|
||||
// 分组选择:按成员类型计算公共 Sections
|
||||
if (selectedGroup.value) {
|
||||
const types = [...new Set(selectedGroup.value.layers.map(l => l.type))]
|
||||
if (types.length === 1) {
|
||||
return SECTION_MAP[types[0]] ?? FALLBACK_SECTIONS
|
||||
}
|
||||
return MULTI_SELECT_SECTIONS
|
||||
}
|
||||
|
||||
if (!selectedLayer.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const type = selectedLayer.value.type
|
||||
return SECTION_MAP[type] ?? FALLBACK_SECTIONS
|
||||
})
|
||||
|
||||
return { sections }
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
<script setup lang="ts">
|
||||
import type { CanvasView } from '../../types'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useCanvasState } from '../../context/state'
|
||||
import { setEditorEmitter } from '../../emitter'
|
||||
import { getCommonLayerFieldValue, isMixedGroupFieldValue } from '../../grouping'
|
||||
import { providePropertyContext } from './composables/usePropertyContext'
|
||||
import { usePropertyPanel } from './composables/usePropertyPanel'
|
||||
import { usePropertyPanelDock } from './composables/usePropertyPanelDock'
|
||||
import { usePropertySections } from './composables/usePropertySections'
|
||||
import SectionDataBinding from './sections/SectionDataBinding.vue'
|
||||
|
||||
type BaseLayerDraftField = 'width' | 'height'
|
||||
|
||||
const LEADING_HASH_RE = /^#/
|
||||
const NON_HEX_COLOR_RE = /[^0-9a-f]/gi
|
||||
|
||||
const { canvasView, selectedLayer, selectedGroup, selectedLayerIds } = useCanvasState()
|
||||
|
||||
const { panelRef, visible, shouldRenderPanel, onPropertyUpdate } = usePropertyPanel()
|
||||
const { sections } = usePropertySections()
|
||||
const { isDocked, isDragging, floatingStyle, toggleDock, onDragStart } = usePropertyPanelDock()
|
||||
|
||||
providePropertyContext({
|
||||
updateField: (field, value) => onPropertyUpdate({ field, value }),
|
||||
getFieldState: getSelectionFieldState,
|
||||
})
|
||||
|
||||
const replaceBackgroundInputRef = shallowRef<HTMLInputElement | null>(null)
|
||||
const baseLayerWidthDraft = ref('')
|
||||
const baseLayerHeightDraft = ref('')
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = () => reject(new Error('底图读取失败'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function openBackgroundPicker() {
|
||||
replaceBackgroundInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onReplaceBackgroundChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
const file = target?.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const source = await readFileAsDataUrl(file)
|
||||
if (source) {
|
||||
setEditorEmitter('propertyPanel:replaceBackground', { source })
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[property-panel] failed to replace background', error)
|
||||
}
|
||||
finally {
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectionFieldState(fieldKey: string) {
|
||||
// 统一抽象"单图层 / 分组 / 底图"三种来源
|
||||
if (selectedGroup.value) {
|
||||
if (fieldKey === 'x' || fieldKey === 'y' || fieldKey === 'width' || fieldKey === 'height') {
|
||||
return {
|
||||
value: selectedGroup.value[fieldKey as 'x' | 'y' | 'width' | 'height'],
|
||||
mixed: false,
|
||||
}
|
||||
}
|
||||
|
||||
const commonValue = getCommonLayerFieldValue(selectedGroup.value.layers, fieldKey)
|
||||
return {
|
||||
value: isMixedGroupFieldValue(commonValue) ? '' : commonValue,
|
||||
mixed: isMixedGroupFieldValue(commonValue),
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayer.value) {
|
||||
return {
|
||||
value: get(selectedLayer.value, fieldKey),
|
||||
mixed: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldKey.startsWith('view.')) {
|
||||
const key = fieldKey.replace('view.', '')
|
||||
if (key in canvasView.value) {
|
||||
return {
|
||||
value: canvasView.value[key as keyof CanvasView],
|
||||
mixed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: '',
|
||||
mixed: false,
|
||||
}
|
||||
}
|
||||
|
||||
function getNumericValue(fieldKey: string, fallback = 0) {
|
||||
const state = getSelectionFieldState(fieldKey)
|
||||
if (state.mixed) {
|
||||
return {
|
||||
value: fallback,
|
||||
mixed: true,
|
||||
}
|
||||
}
|
||||
|
||||
const number = typeof state.value === 'number' ? state.value : Number(state.value)
|
||||
return {
|
||||
value: Number.isFinite(number) ? number : fallback,
|
||||
mixed: false,
|
||||
}
|
||||
}
|
||||
|
||||
function getIntegerInputValue(fieldKey: string, fallback = 0) {
|
||||
const state = getNumericValue(fieldKey, fallback)
|
||||
return state.mixed ? '' : String(Math.round(state.value))
|
||||
}
|
||||
|
||||
function getStringInputValue(fieldKey: string, fallback = '') {
|
||||
const state = getSelectionFieldState(fieldKey)
|
||||
if (state.mixed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof state.value === 'string') {
|
||||
return state.value.trim() ? state.value : fallback
|
||||
}
|
||||
|
||||
if (state.value === null || state.value === undefined || state.value === '') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return String(state.value)
|
||||
}
|
||||
|
||||
function syncBaseLayerDraft() {
|
||||
baseLayerWidthDraft.value = getIntegerInputValue('view.imageWidth', 0)
|
||||
baseLayerHeightDraft.value = getIntegerInputValue('view.imageHeight', 0)
|
||||
}
|
||||
|
||||
function onBaseLayerSizeCommit(field: BaseLayerDraftField) {
|
||||
const rawValue = (field === 'width' ? baseLayerWidthDraft.value : baseLayerHeightDraft.value).trim()
|
||||
if (!rawValue) {
|
||||
syncBaseLayerDraft()
|
||||
return
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseFloat(rawValue)
|
||||
if (Number.isNaN(parsedValue)) {
|
||||
syncBaseLayerDraft()
|
||||
return
|
||||
}
|
||||
|
||||
onPropertyUpdate({
|
||||
field: field === 'width' ? 'view.imageWidth' : 'view.imageHeight',
|
||||
value: Math.round(parsedValue),
|
||||
})
|
||||
}
|
||||
|
||||
const isMultiSelect = computed(() => !selectedLayer.value && !selectedGroup.value && selectedLayerIds.value.length > 1)
|
||||
const hasSelectedLayer = computed(() => Boolean(selectedLayer.value || selectedGroup.value || isMultiSelect.value))
|
||||
const showBaseLayerPanel = computed(() => visible.value && !hasSelectedLayer.value)
|
||||
const showEmptyState = computed(() => !visible.value && !hasSelectedLayer.value)
|
||||
const hasCanvasSize = computed(() => canvasView.value.imageWidth > 0 && canvasView.value.imageHeight > 0)
|
||||
const hasBackgroundImage = computed(() => canvasView.value.imageNaturalWidth > 0 && canvasView.value.imageNaturalHeight > 0)
|
||||
const isBaseLayerAspectLocked = computed(() => canvasView.value.backgroundLockAspectRatio !== false)
|
||||
const baseLayerBackgroundInputValue = computed(() => getStringInputValue('view.background', '#FFFFFF').replace(LEADING_HASH_RE, '').toUpperCase())
|
||||
const selectionSummary = computed(() => {
|
||||
if (isMultiSelect.value) {
|
||||
return `已选择 ${selectedLayerIds.value.length} 个图层`
|
||||
}
|
||||
if (!selectedGroup.value) {
|
||||
return ''
|
||||
}
|
||||
return `${selectedGroup.value.groupName} · ${selectedGroup.value.layerIds.length} 个图层`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [selectedLayer.value, selectedGroup.value?.groupId ?? '', canvasView.value.imageWidth, canvasView.value.imageHeight],
|
||||
() => {
|
||||
if (!selectedLayer.value && !selectedGroup.value) {
|
||||
syncBaseLayerDraft()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggleBaseLayerAspectLock() {
|
||||
onPropertyUpdate({
|
||||
field: 'view.backgroundLockAspectRatio',
|
||||
value: !isBaseLayerAspectLocked.value,
|
||||
})
|
||||
}
|
||||
|
||||
function onRemoveBackground() {
|
||||
setEditorEmitter('propertyPanel:removeBackground')
|
||||
}
|
||||
|
||||
function onBackgroundColorInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
const value = target?.value?.trim()
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
onPropertyUpdate({
|
||||
field: 'view.background',
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
function onBackgroundTextInput(value: string) {
|
||||
const normalized = value.trim().replace(LEADING_HASH_RE, '').replace(NON_HEX_COLOR_RE, '').toUpperCase()
|
||||
onPropertyUpdate({
|
||||
field: 'view.background',
|
||||
value: normalized ? `#${normalized}` : '',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
v-if="shouldRenderPanel"
|
||||
ref="panelRef"
|
||||
class="property-panel-shell"
|
||||
:class="{
|
||||
'property-panel-shell--floating': !isDocked,
|
||||
'property-panel-shell--dragging': isDragging,
|
||||
}"
|
||||
:style="floatingStyle"
|
||||
@click.stop
|
||||
>
|
||||
<section class="property-panel" :class="{ 'property-panel--floating': !isDocked }">
|
||||
<header
|
||||
class="property-header"
|
||||
:class="{ 'property-header--draggable': !isDocked }"
|
||||
@mousedown="onDragStart"
|
||||
>
|
||||
<div class="title-block">
|
||||
<span class="title">属性</span>
|
||||
<span v-if="selectionSummary" class="selection-summary">{{ selectionSummary }}</span>
|
||||
<span v-else-if="showBaseLayerPanel" class="selection-summary">背景图层</span>
|
||||
<span v-else class="selection-summary">选择单个图层后可编辑属性</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="dock-toggle-btn"
|
||||
:title="isDocked ? '浮动面板' : '停靠面板'"
|
||||
@mousedown.stop
|
||||
@click.stop="toggleDock"
|
||||
>
|
||||
<i class="fa-solid" :class="isDocked ? 'fa-up-right-and-down-left-from-center' : 'fa-down-left-and-up-right-to-center'" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 普通图层面板:动态 Section 列表 -->
|
||||
<div v-if="hasSelectedLayer" class="property-body">
|
||||
<!-- 多选批量编辑提示 -->
|
||||
<div v-if="isMultiSelect" class="multi-select-hint">
|
||||
修改以下属性将应用到所有选中图层
|
||||
</div>
|
||||
|
||||
<!-- 动态 Section 列表 -->
|
||||
<component
|
||||
:is="entry.component"
|
||||
v-for="entry in sections"
|
||||
:key="entry.key"
|
||||
/>
|
||||
|
||||
<!-- 数据绑定(仅单选/分组时显示,pidController/valveController 不需要) -->
|
||||
<SectionDataBinding v-if="!isMultiSelect && selectedLayer?.type !== 'pidController' && selectedLayer?.type !== 'valveController'" />
|
||||
</div>
|
||||
|
||||
<!-- 底图面板 -->
|
||||
<div v-else-if="showBaseLayerPanel" class="property-body">
|
||||
<section class="section">
|
||||
<div class="section-title-row">
|
||||
<span class="base-layer-section-title">基础图层</span>
|
||||
<input
|
||||
ref="replaceBackgroundInputRef"
|
||||
class="base-layer-file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="onReplaceBackgroundChange"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="hasCanvasSize" class="base-layer-stack">
|
||||
<div class="base-layer-size-row">
|
||||
<div class="geo-cell">
|
||||
<span class="geo-key">W</span>
|
||||
<el-input
|
||||
v-model="baseLayerWidthDraft"
|
||||
class="geo-input"
|
||||
@keyup.enter="onBaseLayerSizeCommit('width')"
|
||||
@blur="onBaseLayerSizeCommit('width')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="geo-cell">
|
||||
<span class="geo-key">H</span>
|
||||
<el-input
|
||||
v-model="baseLayerHeightDraft"
|
||||
class="geo-input"
|
||||
@keyup.enter="onBaseLayerSizeCommit('height')"
|
||||
@blur="onBaseLayerSizeCommit('height')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="base-layer-lock-btn base-layer-lock-btn--icon"
|
||||
:class="{ active: isBaseLayerAspectLocked }"
|
||||
title="锁定宽高比"
|
||||
@click="toggleBaseLayerAspectLock"
|
||||
>
|
||||
<i class="fa-solid" :class="isBaseLayerAspectLocked ? 'fa-lock' : 'fa-lock-open'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="base-layer-fill">
|
||||
<div class="section-title-row">
|
||||
<span class="base-layer-fill-title">填充</span>
|
||||
</div>
|
||||
|
||||
<div class="base-layer-fill-row base-layer-fill-row--color">
|
||||
<div class="base-layer-bg-control">
|
||||
<input
|
||||
type="color"
|
||||
class="base-layer-color-picker"
|
||||
:value="getStringInputValue('view.background', '#FFFFFF')"
|
||||
title="背景色"
|
||||
aria-label="背景色"
|
||||
@input="onBackgroundColorInput"
|
||||
>
|
||||
<el-input
|
||||
class="base-layer-bg-input"
|
||||
:model-value="baseLayerBackgroundInputValue"
|
||||
@update:model-value="onBackgroundTextInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="base-layer-fill-row">
|
||||
<div class="base-layer-image-label" title="图片填充" aria-label="图片填充">
|
||||
<span class="base-layer-image-icon">
|
||||
<i class="fa-solid fa-image" />
|
||||
</span>
|
||||
<span class="base-layer-image-text">图片填充</span>
|
||||
</div>
|
||||
<div class="base-layer-actions">
|
||||
<button type="button" class="base-layer-action-link" @click="openBackgroundPicker">
|
||||
{{ hasBackgroundImage ? '替换' : '设置' }}
|
||||
</button>
|
||||
<button v-if="hasBackgroundImage" type="button" class="base-layer-action-link base-layer-action-link--danger" @click="onRemoveBackground">
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-tips">
|
||||
请先设置底图,再设置背景图层属性
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="showEmptyState" class="property-empty">
|
||||
<i class="fa-solid fa-sliders" />
|
||||
<span>请选择图层、分组或底图后查看属性</span>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.property-panel-shell {
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
width: var(--ed-sidebar-width);
|
||||
font-family: var(--ed-font);
|
||||
}
|
||||
|
||||
.property-panel-shell--floating {
|
||||
flex: 0 0 0;
|
||||
width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.property-panel-shell--dragging {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.property-panel-shell button,
|
||||
.property-panel-shell input,
|
||||
.property-panel-shell textarea,
|
||||
.property-panel-shell select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.property-panel {
|
||||
width: var(--ed-sidebar-width);
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--ed-border);
|
||||
background: var(--ed-bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.property-panel--floating {
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
border-left: none;
|
||||
border: 1px solid var(--ed-border);
|
||||
border-radius: var(--ed-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.property-header {
|
||||
min-height: 48px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--ed-border);
|
||||
background: var(--ed-bg-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.property-header--draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.property-panel-shell--dragging .property-header--draggable {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dock-toggle-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--ed-radius-sm);
|
||||
color: var(--ed-text-tertiary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
transition: color var(--ed-transition-fast), background var(--ed-transition-fast);
|
||||
}
|
||||
|
||||
.dock-toggle-btn:hover {
|
||||
background: var(--ed-bg-hover);
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--ed-font-md);
|
||||
line-height: 1;
|
||||
font-weight: var(--ed-font-weight-semibold);
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
font-size: var(--ed-font-sm);
|
||||
color: var(--ed-text-tertiary);
|
||||
}
|
||||
|
||||
.property-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.property-body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.property-empty {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 24px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
color: var(--ed-text-tertiary);
|
||||
font-size: var(--ed-font-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.property-empty i {
|
||||
font-size: 16px;
|
||||
color: var(--ed-text-disabled);
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: var(--ed-space-5) var(--ed-space-4);
|
||||
border-bottom: 1px solid var(--ed-border);
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.base-layer-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.base-layer-stack {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.base-layer-size-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.geo-cell {
|
||||
min-height: 28px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: var(--ed-bg-surface);
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.geo-key {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ed-text-tertiary);
|
||||
}
|
||||
|
||||
.geo-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.base-layer-lock-btn {
|
||||
height: 28px;
|
||||
min-width: 20px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ed-text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.base-layer-lock-btn--icon {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.base-layer-lock-btn:hover {
|
||||
background: transparent;
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.base-layer-lock-btn.active {
|
||||
color: var(--ed-text-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.base-layer-actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.base-layer-action-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
color: var(--ed-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.base-layer-action-link:hover {
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.base-layer-action-link--danger {
|
||||
color: var(--ed-danger);
|
||||
}
|
||||
|
||||
.base-layer-bg-control {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.base-layer-fill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.base-layer-fill-row {
|
||||
min-height: 28px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: var(--ed-bg-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.base-layer-fill-row--color {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.base-layer-color-picker {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.base-layer-color-picker::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.base-layer-color-picker::-webkit-color-swatch {
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.base-layer-bg-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.base-layer-image-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.base-layer-image-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 20px;
|
||||
font-size: 16px;
|
||||
color: var(--ed-text-tertiary);
|
||||
}
|
||||
|
||||
.base-layer-image-text {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multi-select-hint {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--ed-accent, #1677ff);
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
border-bottom: 1px solid var(--ed-border);
|
||||
}
|
||||
|
||||
.empty-tips {
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--ed-border);
|
||||
padding: 10px;
|
||||
background: var(--ed-bg-surface);
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-tertiary);
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.base-layer-section-title,
|
||||
.base-layer-fill-title {
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
font-weight: 600;
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ghost-icon-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--ed-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-icon-btn:hover {
|
||||
background: var(--ed-bg-hover);
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.two-cols {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
:deep(.geo-input .el-input__inner) {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.base-layer-bg-input .el-input__inner) {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.geo-input .el-input__wrapper),
|
||||
:deep(.base-layer-bg-input .el-input__wrapper) {
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper) {
|
||||
min-height: var(--ed-control-height);
|
||||
height: var(--ed-control-height);
|
||||
border-radius: var(--ed-radius-sm);
|
||||
background: var(--ed-bg-surface);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0 6px;
|
||||
font-size: var(--ed-font-sm);
|
||||
}
|
||||
|
||||
:deep(.el-select__placeholder),
|
||||
:deep(.el-select__selected-item) {
|
||||
font-size: var(--ed-font-sm);
|
||||
}
|
||||
|
||||
:deep(.el-cascader) {
|
||||
width: 100%;
|
||||
line-height: var(--ed-control-height);
|
||||
}
|
||||
|
||||
:deep(.el-cascader .el-input__wrapper) {
|
||||
min-height: var(--ed-control-height);
|
||||
height: var(--ed-control-height);
|
||||
border-radius: var(--ed-radius-sm);
|
||||
background: var(--ed-bg-surface);
|
||||
border: none;
|
||||
padding: 0 6px;
|
||||
font-size: var(--ed-font-sm);
|
||||
}
|
||||
|
||||
:deep(.el-input-number.is-controls-right) {
|
||||
height: var(--ed-control-height);
|
||||
line-height: var(--ed-control-height);
|
||||
}
|
||||
|
||||
:deep(.el-input-number.is-controls-right .el-input__wrapper) {
|
||||
background: var(--ed-bg-surface);
|
||||
border: none;
|
||||
border-radius: var(--ed-radius-sm);
|
||||
padding-right: 20px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-input-number.is-controls-right .el-input__inner) {
|
||||
font-size: var(--ed-font-sm);
|
||||
}
|
||||
|
||||
:deep(.el-input-number .el-input-number__decrease),
|
||||
:deep(.el-input-number .el-input-number__increase) {
|
||||
width: 18px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.two-cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.base-layer-fill-row {
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.base-layer-bg-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdSection } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { setEditorEmitter } from '../../../emitter'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer, selectedGroup } = useCanvasState()
|
||||
const { updateField: ctxUpdateField } = usePropertyContext()
|
||||
|
||||
const config = computed(() => selectedLayer.value?.config ?? {})
|
||||
|
||||
function updateField(field: string, value: unknown) {
|
||||
ctxUpdateField(`config.${field}`, value)
|
||||
}
|
||||
|
||||
const direction = computed({
|
||||
get: () => config.value.direction ?? 'vertical',
|
||||
set: (v: string) => {
|
||||
updateField('direction', v)
|
||||
// 切换方向时自动交换每个图层宽高
|
||||
if (selectedGroup.value) {
|
||||
for (const layer of selectedGroup.value.layers) {
|
||||
setEditorEmitter('layer:updateField', { id: layer.id, field: 'width', value: layer.height })
|
||||
setEditorEmitter('layer:updateField', { id: layer.id, field: 'height', value: layer.width })
|
||||
}
|
||||
}
|
||||
else if (selectedLayer.value) {
|
||||
const layer = selectedLayer.value
|
||||
setEditorEmitter('layer:updateField', { id: layer.id, field: 'width', value: layer.height })
|
||||
setEditorEmitter('layer:updateField', { id: layer.id, field: 'height', value: layer.width })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const minVal = computed({
|
||||
get: () => config.value.min ?? 0,
|
||||
set: (v: number) => updateField('min', v),
|
||||
})
|
||||
|
||||
const maxVal = computed({
|
||||
get: () => config.value.max ?? 100,
|
||||
set: (v: number) => updateField('max', v),
|
||||
})
|
||||
|
||||
const showValue = computed({
|
||||
get: () => config.value.showValue ?? true,
|
||||
set: (v: boolean) => updateField('showValue', v),
|
||||
})
|
||||
|
||||
const foregroundColor = computed({
|
||||
get: () => config.value.foregroundColor ?? '#00ff00',
|
||||
set: (v: string) => updateField('foregroundColor', v),
|
||||
})
|
||||
|
||||
const valueColor = computed({
|
||||
get: () => config.value.valueColor ?? '#ffffff',
|
||||
set: (v: string) => updateField('valueColor', v),
|
||||
})
|
||||
|
||||
const valueFontSize = computed({
|
||||
get: () => config.value.valueFontSize ?? 14,
|
||||
set: (v: number) => updateField('valueFontSize', v),
|
||||
})
|
||||
|
||||
const valueFontWeight = computed({
|
||||
get: () => config.value.valueFontWeight ?? 600,
|
||||
set: (v: number) => updateField('valueFontWeight', v),
|
||||
})
|
||||
|
||||
const valueContent = computed({
|
||||
get: () => config.value.valueContent ?? '',
|
||||
set: (v: string) => updateField('valueContent', v || undefined),
|
||||
})
|
||||
|
||||
const colors = computed<Array<{ threshold: number, color: string }>>(() => config.value.colors ?? [])
|
||||
|
||||
function addColor() {
|
||||
const next = [...colors.value, { threshold: 0, color: '#1677ff' }]
|
||||
updateField('colors', next)
|
||||
}
|
||||
|
||||
function removeColor(index: number) {
|
||||
const next = colors.value.filter((_, i) => i !== index)
|
||||
updateField('colors', next)
|
||||
}
|
||||
|
||||
function updateColor(index: number, field: 'threshold' | 'color', value: number | string) {
|
||||
const next = colors.value.map((item, i) =>
|
||||
i === index ? { ...item, [field]: value } : item,
|
||||
)
|
||||
updateField('colors', next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="棒图配置">
|
||||
<div class="config-row">
|
||||
<span class="config-label">方向</span>
|
||||
<el-radio-group v-model="direction" size="small">
|
||||
<el-radio-button value="vertical">
|
||||
垂直
|
||||
</el-radio-button>
|
||||
<el-radio-button value="horizontal">
|
||||
水平
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<span class="config-label">最小值</span>
|
||||
<el-input-number v-model="minVal" size="small" :controls="false" class="config-input" />
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<span class="config-label">最大值</span>
|
||||
<el-input-number v-model="maxVal" size="small" :controls="false" class="config-input" />
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<span class="config-label">前景色</span>
|
||||
<el-color-picker
|
||||
v-model="foregroundColor"
|
||||
size="small"
|
||||
@update:model-value="(v: string | null) => foregroundColor = v ?? '#00ff00'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-row">
|
||||
<span class="config-label">阈值颜色</span>
|
||||
<el-button size="small" text type="primary" @click="addColor">
|
||||
+ 添加
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-for="(item, index) in colors" :key="index" class="color-row">
|
||||
<el-input-number
|
||||
:model-value="item.threshold"
|
||||
size="small"
|
||||
:controls="false"
|
||||
class="threshold-input"
|
||||
@update:model-value="(v: number | undefined) => updateColor(index, 'threshold', v ?? 0)"
|
||||
/>
|
||||
<el-color-picker
|
||||
:model-value="item.color"
|
||||
size="small"
|
||||
@update:model-value="(v: string | null) => updateColor(index, 'color', v ?? '#00ff00')"
|
||||
/>
|
||||
<el-button size="small" text type="danger" @click="removeColor(index)">
|
||||
<i class="fa-solid fa-trash-can" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</EdSection>
|
||||
|
||||
<EdSection title="数值配置">
|
||||
<div class="config-row">
|
||||
<span class="config-label">显示数值</span>
|
||||
<el-switch v-model="showValue" size="small" />
|
||||
</div>
|
||||
|
||||
<template v-if="showValue">
|
||||
<div class="config-row">
|
||||
<span class="config-label">数值内容</span>
|
||||
<el-input
|
||||
v-model="valueContent"
|
||||
size="small"
|
||||
placeholder="默认百分比"
|
||||
clearable
|
||||
class="config-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">字体颜色</span>
|
||||
<el-color-picker
|
||||
v-model="valueColor"
|
||||
size="small"
|
||||
@update:model-value="(v: string | null) => valueColor = v ?? '#ffffff'"
|
||||
/>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">字号</span>
|
||||
<el-input-number v-model="valueFontSize" size="small" :min="10" :max="72" :controls="false" class="config-input" />
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">字重</span>
|
||||
<el-select v-model="valueFontWeight" size="small" class="config-input">
|
||||
<el-option :value="300" label="细体" />
|
||||
<el-option :value="400" label="常规" />
|
||||
<el-option :value="500" label="中等" />
|
||||
<el-option :value="600" label="半粗" />
|
||||
<el-option :value="700" label="粗体" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.config-label {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
}
|
||||
.config-input { width: 100px; }
|
||||
.config-section { margin-top: 4px; }
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 56px;
|
||||
}
|
||||
.threshold-input { width: 80px; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdSection } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer, canvasList } = useCanvasState()
|
||||
const { updateField: ctxUpdateField } = usePropertyContext()
|
||||
const config = computed(() => selectedLayer.value?.config ?? {})
|
||||
|
||||
function updateField(field: string, value: unknown) {
|
||||
ctxUpdateField(`config.${field}`, value)
|
||||
}
|
||||
|
||||
const label = computed({
|
||||
get: () => config.value.label ?? '按钮',
|
||||
set: (v: string) => updateField('label', v),
|
||||
})
|
||||
const buttonType = computed({
|
||||
get: () => config.value.buttonType ?? 'trigger',
|
||||
set: (v: string) => updateField('buttonType', v),
|
||||
})
|
||||
const targetCanvas = computed({
|
||||
get: () => config.value.targetCanvas ?? '',
|
||||
set: (v: string) => updateField('targetCanvas', v),
|
||||
})
|
||||
const confirmRequired = computed({
|
||||
get: () => config.value.confirmRequired ?? false,
|
||||
set: (v: boolean) => updateField('confirmRequired', v),
|
||||
})
|
||||
const confirmMessage = computed({
|
||||
get: () => config.value.confirmMessage ?? '确认执行此操作?',
|
||||
set: (v: string) => updateField('confirmMessage', v),
|
||||
})
|
||||
const canvasOptions = computed(() =>
|
||||
(canvasList.value ?? []).map(c => ({ label: c.name, value: c.id })),
|
||||
)
|
||||
const buttonTypeOptions = [
|
||||
{ label: '触发', value: 'trigger' },
|
||||
{ label: '切换', value: 'toggle' },
|
||||
{ label: '导航', value: 'navigate' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="按钮配置">
|
||||
<div class="config-row">
|
||||
<span class="config-label">按钮文字</span>
|
||||
<el-input v-model="label" size="small" class="config-input" />
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">按钮类型</span>
|
||||
<el-select v-model="buttonType" size="small" class="config-input">
|
||||
<el-option v-for="opt in buttonTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="buttonType === 'navigate'" class="config-row">
|
||||
<span class="config-label">目标画布</span>
|
||||
<el-select v-model="targetCanvas" size="small" class="config-input" placeholder="选择画布">
|
||||
<el-option v-for="opt in canvasOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">二次确认</span>
|
||||
<el-switch v-model="confirmRequired" size="small" />
|
||||
</div>
|
||||
<div v-if="confirmRequired" class="config-row">
|
||||
<span class="config-label">确认提示</span>
|
||||
<el-input v-model="confirmMessage" size="small" class="config-input" />
|
||||
</div>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.config-label {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
}
|
||||
.config-input {
|
||||
flex: 1;
|
||||
max-width: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdColorInput, EdSection } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer, canvasList } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const items = computed<Array<{ canvasId: string }>>(() => selectedLayer.value?.config?.items ?? [])
|
||||
const activeColor = computed(() => String(selectedLayer.value?.config?.activeColor ?? '#E1E1E1'))
|
||||
const inactiveColor = computed(() => String(selectedLayer.value?.config?.inactiveColor ?? '#C8C8C8'))
|
||||
const activeTextColor = computed(() => String(selectedLayer.value?.config?.activeTextColor ?? '#333333'))
|
||||
const inactiveTextColor = computed(() => String(selectedLayer.value?.config?.inactiveTextColor ?? '#333333'))
|
||||
|
||||
const canvasOptions = computed(() => (canvasList.value ?? []).map(c => ({ label: c.name, value: c.id })))
|
||||
|
||||
function addItem() {
|
||||
updateField('config.items', [...items.value, { canvasId: '' }])
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
updateField('config.items', items.value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function updateItemCanvas(index: number, canvasId: string) {
|
||||
updateField('config.items', items.value.map((item, i) => i === index ? { ...item, canvasId } : item))
|
||||
}
|
||||
|
||||
function moveItem(from: number, to: number) {
|
||||
if (to < 0 || to >= items.value.length)
|
||||
return
|
||||
const next = [...items.value]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved)
|
||||
updateField('config.items', next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="切页按钮">
|
||||
<div class="cg-items">
|
||||
<div v-for="(item, index) in items" :key="index" class="cg-item">
|
||||
<div class="cg-item-header">
|
||||
<span class="cg-item-index">#{{ index + 1 }}</span>
|
||||
<div class="cg-item-actions">
|
||||
<el-button size="small" text :disabled="index === 0" @click="moveItem(index, index - 1)">
|
||||
<i class="fa-solid fa-arrow-up" />
|
||||
</el-button>
|
||||
<el-button size="small" text :disabled="index === items.length - 1" @click="moveItem(index, index + 1)">
|
||||
<i class="fa-solid fa-arrow-down" />
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="removeItem(index)">
|
||||
<i class="fa-solid fa-trash-can" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-item-body">
|
||||
<div class="cg-field">
|
||||
<span class="cg-label">画布</span>
|
||||
<el-select :model-value="item.canvasId" size="small" placeholder="选择画布" @update:model-value="(v: string) => updateItemCanvas(index, v)">
|
||||
<el-option v-for="opt in canvasOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-button size="small" type="primary" text class="cg-add-btn" @click="addItem">
|
||||
+ 添加切换项
|
||||
</el-button>
|
||||
|
||||
<div class="cg-colors">
|
||||
<div class="cg-color-row">
|
||||
<span class="cg-label">激活色</span>
|
||||
<EdColorInput :model-value="activeColor" @update:model-value="updateField('config.activeColor', $event)" />
|
||||
</div>
|
||||
<div class="cg-color-row">
|
||||
<span class="cg-label">默认色</span>
|
||||
<EdColorInput :model-value="inactiveColor" @update:model-value="updateField('config.inactiveColor', $event)" />
|
||||
</div>
|
||||
<div class="cg-color-row">
|
||||
<span class="cg-label">激活文字</span>
|
||||
<EdColorInput :model-value="activeTextColor" @update:model-value="updateField('config.activeTextColor', $event)" />
|
||||
</div>
|
||||
<div class="cg-color-row">
|
||||
<span class="cg-label">默认文字</span>
|
||||
<EdColorInput :model-value="inactiveTextColor" @update:model-value="updateField('config.inactiveTextColor', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cg-items { display: flex; flex-direction: column; gap: 8px; }
|
||||
.cg-item { border: 1px solid #f0f0f0; border-radius: 4px; padding: 8px; }
|
||||
.cg-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.cg-item-index { font-size: 11px; color: #8c8c8c; font-weight: 600; }
|
||||
.cg-item-actions { display: flex; gap: 2px; }
|
||||
.cg-item-body { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cg-field { display: flex; align-items: center; gap: 8px; }
|
||||
.cg-label { font-size: 12px; color: #606266; min-width: 48px; flex-shrink: 0; }
|
||||
.cg-add-btn { width: 100%; margin-top: 4px; }
|
||||
.cg-colors { display: flex; flex-direction: column; gap: var(--ed-space-4); margin-top: 12px; border-top: 1px solid #f0f0f0; padding-top: 8px; }
|
||||
.cg-color-row { display: flex; align-items: center; gap: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentEvent } from '../../../types'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { generateUUID } from '@/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
isEditing: boolean
|
||||
editingEvent?: ComponentEvent | null
|
||||
canvasOptions: { label: string, value: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'save', data: ComponentEvent): void
|
||||
}>()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: value => emit('update:visible', value),
|
||||
})
|
||||
|
||||
// 表单状态
|
||||
const eventId = ref('')
|
||||
const trigger = ref<'click' | 'dblclick'>('click')
|
||||
const condition = ref('')
|
||||
const actionType = ref('callMethod')
|
||||
const actionPayload = ref<Record<string, unknown>>({})
|
||||
|
||||
const triggerOptions = [
|
||||
{ label: '单击', value: 'click' },
|
||||
{ label: '双击', value: 'dblclick' },
|
||||
]
|
||||
const actionTypeOptions = [
|
||||
{ label: '调用方法', value: 'callMethod' },
|
||||
{ label: '切换画布', value: 'navigateCanvas' },
|
||||
]
|
||||
|
||||
const dialogTitle = computed(() => props.isEditing ? '编辑事件' : '添加事件')
|
||||
|
||||
// 初始化表单
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) {
|
||||
const e = props.editingEvent
|
||||
if (e) {
|
||||
eventId.value = e.id
|
||||
trigger.value = e.trigger
|
||||
condition.value = e.condition ?? ''
|
||||
actionType.value = e.action.type
|
||||
actionPayload.value = e.action.payload ? { ...e.action.payload } : {}
|
||||
}
|
||||
else {
|
||||
eventId.value = generateUUID()
|
||||
trigger.value = 'click'
|
||||
condition.value = ''
|
||||
actionType.value = 'callMethod'
|
||||
actionPayload.value = {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 切换动作类型时清空 payload
|
||||
watch(actionType, () => {
|
||||
actionPayload.value = {}
|
||||
})
|
||||
|
||||
const saveDisabled = computed(() => {
|
||||
if (actionType.value === 'navigateCanvas' && !actionPayload.value.canvasId)
|
||||
return true
|
||||
return false
|
||||
})
|
||||
|
||||
// 预览文本
|
||||
const triggerLabels: Record<string, string> = { click: '单击', dblclick: '双击' }
|
||||
const actionLabels: Record<string, string> = { callMethod: '调用方法', navigateCanvas: '切换画布' }
|
||||
|
||||
const previewText = computed(() => {
|
||||
const parts: string[] = []
|
||||
parts.push(triggerLabels[trigger.value] ?? trigger.value)
|
||||
parts.push('→')
|
||||
parts.push(actionLabels[actionType.value] ?? actionType.value)
|
||||
|
||||
if (actionType.value === 'callMethod' && actionPayload.value.method) {
|
||||
parts.push(`(${actionPayload.value.method})`)
|
||||
}
|
||||
else if (actionType.value === 'navigateCanvas' && actionPayload.value.canvasId) {
|
||||
const name = props.canvasOptions.find(c => c.value === actionPayload.value.canvasId)?.label
|
||||
if (name)
|
||||
parts.push(`(${name})`)
|
||||
}
|
||||
|
||||
if (condition.value)
|
||||
parts.push(`| 条件: ${condition.value}`)
|
||||
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
function onSave() {
|
||||
emit('save', {
|
||||
id: eventId.value,
|
||||
trigger: trigger.value,
|
||||
condition: condition.value || undefined,
|
||||
action: {
|
||||
type: actionType.value,
|
||||
payload: Object.keys(actionPayload.value).length ? actionPayload.value : undefined,
|
||||
},
|
||||
})
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="480px"
|
||||
:append-to-body="true"
|
||||
destroy-on-close
|
||||
@close="onCancel"
|
||||
>
|
||||
<div class="dialog-form">
|
||||
<div class="form-row">
|
||||
<span class="form-label">触发方式</span>
|
||||
<el-select v-model="trigger" class="form-control">
|
||||
<el-option
|
||||
v-for="opt in triggerOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<span class="form-label">条件</span>
|
||||
<el-input
|
||||
v-model="condition"
|
||||
class="form-control"
|
||||
placeholder="可选,如: vars['Pump.Status'] === 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<span class="form-label">动作类型</span>
|
||||
<el-select v-model="actionType" class="form-control">
|
||||
<el-option
|
||||
v-for="opt in actionTypeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 调用方法 -->
|
||||
<div v-if="actionType === 'callMethod'" class="form-row">
|
||||
<span class="form-label">方法名</span>
|
||||
<el-input
|
||||
:model-value="(actionPayload.method as string) ?? ''"
|
||||
class="form-control"
|
||||
placeholder="预留"
|
||||
@update:model-value="(v: string) => actionPayload = { method: v }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 切换画布 -->
|
||||
<div v-if="actionType === 'navigateCanvas'" class="form-row">
|
||||
<span class="form-label">目标画布</span>
|
||||
<el-select
|
||||
:model-value="(actionPayload.canvasId as string) ?? ''"
|
||||
filterable
|
||||
class="form-control"
|
||||
placeholder="选择画布"
|
||||
@update:model-value="(v: string) => actionPayload = { canvasId: v }"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in canvasOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览 -->
|
||||
<div class="dialog-preview">
|
||||
<span class="preview-label">预览</span>
|
||||
<div class="preview-box">
|
||||
<span class="preview-text">{{ previewText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="onCancel">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="saveDisabled" @click="onSave">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 与 BindingConfigDialog 保持一致的表单样式
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--ed-text-secondary, #6b6b6b);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-preview {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ed-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-tertiary, #8c8c8c);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
padding: 10px 12px;
|
||||
background: var(--ed-bg-raised, #fafafa);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--ed-border, #f0f0f0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--ed-text-primary, #595959);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentEvent } from '../../../types'
|
||||
import { computed, ref } from 'vue'
|
||||
import { EdIconButton, EdSection } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { setEditorEmitter } from '../../../emitter'
|
||||
import EventConfigDialog from './EventConfigDialog.vue'
|
||||
|
||||
const { selectedLayer, selectedGroup, canvasList } = useCanvasState()
|
||||
const events = computed<ComponentEvent[]>(() => selectedLayer.value?.events ?? [])
|
||||
const canEdit = computed(() => Boolean(selectedLayer.value && !selectedGroup.value))
|
||||
|
||||
// 对话框状态
|
||||
const dialogVisible = ref(false)
|
||||
const dialogIsEditing = ref(false)
|
||||
const dialogEditingEvent = ref<ComponentEvent | null>(null)
|
||||
|
||||
const canvasOptions = computed(() => (canvasList.value ?? []).map(c => ({ label: c.name, value: c.id })))
|
||||
|
||||
function updateEvents(next: ComponentEvent[]) {
|
||||
if (!selectedLayer.value)
|
||||
return
|
||||
setEditorEmitter('layer:updateField', {
|
||||
id: selectedLayer.value.id,
|
||||
field: 'events',
|
||||
value: next.length ? next : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 标签映射
|
||||
const triggerLabels: Record<string, string> = { click: '单击', dblclick: '双击' }
|
||||
const actionLabels: Record<string, string> = { callMethod: '调用方法', navigateCanvas: '切换画布' }
|
||||
|
||||
function getCanvasName(id: string) {
|
||||
return canvasList.value?.find(c => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function getEventIcon(event: ComponentEvent) {
|
||||
return event.trigger === 'dblclick' ? 'fa-solid fa-computer-mouse' : 'fa-solid fa-hand-pointer'
|
||||
}
|
||||
|
||||
function getEventLabel(event: ComponentEvent) {
|
||||
return actionLabels[event.action.type] ?? event.action.type
|
||||
}
|
||||
|
||||
function getEventDetail(event: ComponentEvent) {
|
||||
const parts: string[] = [triggerLabels[event.trigger] ?? event.trigger]
|
||||
if (event.action.type === 'navigateCanvas' && event.action.payload?.canvasId)
|
||||
parts.push(`→ ${getCanvasName(event.action.payload.canvasId as string)}`)
|
||||
else if (event.action.type === 'callMethod' && event.action.payload?.method)
|
||||
parts.push(`→ ${event.action.payload.method}`)
|
||||
if (event.condition)
|
||||
parts.push(`| ${event.condition}`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
// 添加事件
|
||||
function openAddDialog() {
|
||||
dialogIsEditing.value = false
|
||||
dialogEditingEvent.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑事件
|
||||
function openEditDialog(event: ComponentEvent) {
|
||||
dialogIsEditing.value = true
|
||||
dialogEditingEvent.value = {
|
||||
...event,
|
||||
action: {
|
||||
...event.action,
|
||||
payload: event.action.payload ? { ...event.action.payload } : undefined,
|
||||
},
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 保存事件(来自弹窗)
|
||||
function onSaveEvent(data: ComponentEvent) {
|
||||
if (dialogIsEditing.value) {
|
||||
updateEvents(events.value.map(e => e.id === data.id ? data : e))
|
||||
}
|
||||
else {
|
||||
updateEvents([...events.value, data])
|
||||
}
|
||||
}
|
||||
|
||||
// 删除事件
|
||||
function deleteEvent(event: ComponentEvent) {
|
||||
updateEvents(events.value.filter(e => e.id !== event.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection v-if="canEdit" title="事件">
|
||||
<template #actions>
|
||||
<EdIconButton icon="fa-solid fa-plus" title="添加事件" @click="openAddDialog" />
|
||||
</template>
|
||||
|
||||
<!-- 事件卡片列表 -->
|
||||
<div v-if="events.length" class="event-list">
|
||||
<div
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
class="event-item"
|
||||
>
|
||||
<i class="event-item__icon" :class="getEventIcon(event)" />
|
||||
<div class="event-item__info">
|
||||
<span class="event-item__label">{{ getEventLabel(event) }}</span>
|
||||
<span class="event-item__detail">{{ getEventDetail(event) }}</span>
|
||||
</div>
|
||||
<div class="event-item__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="event-item__btn"
|
||||
title="编辑"
|
||||
@click="openEditDialog(event)"
|
||||
>
|
||||
<i class="fa-solid fa-pen-to-square" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="event-item__btn event-item__btn--danger"
|
||||
title="删除"
|
||||
@click="deleteEvent(event)"
|
||||
>
|
||||
<i class="fa-solid fa-trash-can" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="event-empty">
|
||||
暂无事件。点击 + 添加。
|
||||
</div>
|
||||
|
||||
<!-- 单事件配置对话框 -->
|
||||
<EventConfigDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:is-editing="dialogIsEditing"
|
||||
:editing-event="dialogEditingEvent"
|
||||
:canvas-options="canvasOptions"
|
||||
@save="onSaveEvent"
|
||||
/>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 与 SectionDataBinding 的 binding-list / binding-item 保持一致
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--ed-border);
|
||||
background: var(--ed-bg-surface);
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ed-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.event-item__icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--ed-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.event-item__label {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-item__detail {
|
||||
font-size: 11px;
|
||||
color: var(--ed-text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-item__btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ed-text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--ed-bg-hover);
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.event-empty {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-disabled);
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 渐变编辑器 — 线性/径向渐变色标编辑
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { EdColorInput, EdNumberInput, EdSegmented } from '../../../../controls'
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'linear' | 'radial'
|
||||
colors: string[]
|
||||
stops: number[]
|
||||
angle: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:type', val: 'linear' | 'radial'): void
|
||||
(e: 'update:colors', val: string[]): void
|
||||
(e: 'update:stops', val: number[]): void
|
||||
(e: 'update:angle', val: number): void
|
||||
}>()
|
||||
|
||||
const gradientTypeOptions = [
|
||||
{ label: '线性', value: 'linear' },
|
||||
{ label: '径向', value: 'radial' },
|
||||
]
|
||||
|
||||
// 渐变预览条
|
||||
const previewGradient = computed(() => {
|
||||
const stops = props.colors.map((c, i) => {
|
||||
const pos = Math.round((props.stops[i] ?? (i / Math.max(props.colors.length - 1, 1))) * 100)
|
||||
return `${c} ${pos}%`
|
||||
}).join(', ')
|
||||
if (props.type === 'radial') {
|
||||
return `radial-gradient(circle, ${stops})`
|
||||
}
|
||||
return `linear-gradient(${props.angle}deg, ${stops})`
|
||||
})
|
||||
|
||||
// 色标列表
|
||||
const colorStops = computed(() =>
|
||||
props.colors.map((color, i) => ({
|
||||
color,
|
||||
position: Math.round((props.stops[i] ?? (i / Math.max(props.colors.length - 1, 1))) * 100),
|
||||
})),
|
||||
)
|
||||
|
||||
function updateColor(index: number, color: string) {
|
||||
const newColors = [...props.colors]
|
||||
newColors[index] = color
|
||||
emit('update:colors', newColors)
|
||||
}
|
||||
|
||||
function updatePosition(index: number, position: number) {
|
||||
const newStops = [...props.stops]
|
||||
newStops[index] = Math.max(0, Math.min(1, position / 100))
|
||||
emit('update:stops', newStops)
|
||||
}
|
||||
|
||||
function addStop() {
|
||||
emit('update:colors', [...props.colors, '#999999'])
|
||||
emit('update:stops', [...props.stops, 0.5])
|
||||
}
|
||||
|
||||
function removeStop(index: number) {
|
||||
if (props.colors.length <= 2)
|
||||
return
|
||||
const newColors = props.colors.filter((_, i) => i !== index)
|
||||
const newStops = props.stops.filter((_, i) => i !== index)
|
||||
emit('update:colors', newColors)
|
||||
emit('update:stops', newStops)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gradient-editor">
|
||||
<!-- 渐变预览条 -->
|
||||
<div class="gradient-preview" :style="{ background: previewGradient }" />
|
||||
|
||||
<!-- 类型 + 角度 -->
|
||||
<div class="gradient-controls">
|
||||
<EdSegmented :model-value="type" :options="gradientTypeOptions" @update:model-value="$emit('update:type', $event as 'linear' | 'radial')" />
|
||||
<EdNumberInput
|
||||
v-if="type === 'linear'"
|
||||
:model-value="angle"
|
||||
:min="0"
|
||||
:max="360"
|
||||
:step="15"
|
||||
label="°"
|
||||
@update:model-value="$emit('update:angle', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 色标列表 -->
|
||||
<div v-for="(stop, index) in colorStops" :key="index" class="stop-row">
|
||||
<EdColorInput
|
||||
:model-value="stop.color"
|
||||
@update:model-value="updateColor(index, $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
:model-value="stop.position"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="5"
|
||||
label="%"
|
||||
@update:model-value="updatePosition(index, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="colors.length > 2"
|
||||
type="button"
|
||||
class="stop-remove-btn"
|
||||
title="移除色标"
|
||||
@click="removeStop(index)"
|
||||
>
|
||||
<i class="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="stop-add-btn" @click="addStop">
|
||||
<i class="fa-solid fa-plus" />
|
||||
添加色标
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gradient-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gradient-preview {
|
||||
height: 20px;
|
||||
border-radius: var(--ed-radius-sm, 4px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.gradient-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
gap: var(--ed-space-4, 4px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stop-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px auto;
|
||||
gap: var(--ed-space-4, 4px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stop-remove-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--ed-radius-sm, 4px);
|
||||
color: var(--ed-text-tertiary, #8c8c8c);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all var(--ed-transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.stop-remove-btn:hover {
|
||||
background: var(--ed-bg-hover, #f5f5f5);
|
||||
color: var(--ed-danger, #ff4d4f);
|
||||
}
|
||||
|
||||
.stop-add-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
height: var(--ed-control-height, 24px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-family: var(--ed-font);
|
||||
font-size: var(--ed-font-sm, 11px);
|
||||
color: var(--ed-text-tertiary, #8c8c8c);
|
||||
cursor: pointer;
|
||||
border-radius: var(--ed-radius-sm, 4px);
|
||||
transition: all var(--ed-transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.stop-add-btn:hover {
|
||||
background: var(--ed-bg-hover, #f5f5f5);
|
||||
color: var(--ed-accent, #1677ff);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdColorInput, EdInput, EdNumberInput, EdSection } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const headerColor = computed(() => String(selectedLayer.value?.config?.headerColor ?? '#0055ff'))
|
||||
const labelColor = computed(() => String(selectedLayer.value?.config?.labelColor ?? '#00cc00'))
|
||||
const valueColor = computed(() => String(selectedLayer.value?.config?.valueColor ?? '#ffffff'))
|
||||
const bgColor = computed(() => String(selectedLayer.value?.config?.bgColor ?? '#1a1a2e'))
|
||||
const decimalPlaces = computed(() => Number(selectedLayer.value?.config?.decimalPlaces ?? 2))
|
||||
const opDecimalPlaces = computed(() => Number(selectedLayer.value?.config?.opDecimalPlaces ?? 2))
|
||||
const description = computed(() => String(selectedLayer.value?.config?.description ?? ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="控制仪表">
|
||||
<div class="ctrl-config-grid">
|
||||
<div class="ctrl-config-item">
|
||||
<span class="ctrl-config-label">描述</span>
|
||||
<EdInput
|
||||
:model-value="description"
|
||||
placeholder="设备描述"
|
||||
@update:model-value="updateField('config.description', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="ctrl-config-item">
|
||||
<span class="ctrl-config-label">小数位数</span>
|
||||
<EdNumberInput
|
||||
:model-value="decimalPlaces"
|
||||
:min="0"
|
||||
:max="6"
|
||||
:step="1"
|
||||
@update:model-value="updateField('config.decimalPlaces', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="ctrl-config-item">
|
||||
<span class="ctrl-config-label">OP小数</span>
|
||||
<EdNumberInput
|
||||
:model-value="opDecimalPlaces"
|
||||
:min="0"
|
||||
:max="6"
|
||||
:step="1"
|
||||
@update:model-value="updateField('config.opDecimalPlaces', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="ctrl-config-item">
|
||||
<span class="ctrl-config-label">标题栏</span>
|
||||
<EdColorInput :model-value="headerColor" @update:model-value="updateField('config.headerColor', $event)" />
|
||||
</div>
|
||||
<div class="ctrl-config-item">
|
||||
<span class="ctrl-config-label">背景色</span>
|
||||
<EdColorInput :model-value="bgColor" @update:model-value="updateField('config.bgColor', $event)" />
|
||||
</div>
|
||||
<div class="ctrl-config-item">
|
||||
<span class="ctrl-config-label">标签色</span>
|
||||
<EdColorInput :model-value="labelColor" @update:model-value="updateField('config.labelColor', $event)" />
|
||||
</div>
|
||||
<div class="ctrl-config-item">
|
||||
<span class="ctrl-config-label">数值色</span>
|
||||
<EdColorInput :model-value="valueColor" @update:model-value="updateField('config.valueColor', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ctrl-config-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
.ctrl-config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ctrl-config-label {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-secondary, #606266);
|
||||
min-width: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,600 @@
|
||||
<script setup lang="ts">
|
||||
import type { LayerBindingDefinition, LayerBindingValue } from '../../../types'
|
||||
import { computed, ref } from 'vue'
|
||||
import { generateUUID } from '@/utils'
|
||||
import { EdIconButton, EdSection } from '../../../../controls'
|
||||
import { useCanvasRuntimeInject } from '../../../context'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { setEditorEmitter } from '../../../emitter'
|
||||
import BindingConfigDialog from './BindingConfigDialog.vue'
|
||||
|
||||
interface BindingFieldOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface SummaryItem {
|
||||
id: string
|
||||
kind: 'binding' | 'conditional'
|
||||
icon: string
|
||||
label: string
|
||||
detail: string
|
||||
enabled: boolean
|
||||
field?: string
|
||||
binding?: LayerBindingDefinition
|
||||
conditional?: { id: string, condition: string, style: Record<string, any>, priority?: number, enabled?: boolean }
|
||||
}
|
||||
|
||||
const VARIABLE_EXPRESSION_RE = /vars\[['"]([^'"]+)['"]\]/g
|
||||
|
||||
const { selectedLayer, selectedGroup } = useCanvasState()
|
||||
const { isLoadingVariables, variableLoadError, variableOptions, ensureVariableCatalogLoaded } = useCanvasRuntimeInject()
|
||||
|
||||
const canEdit = computed(() => Boolean(selectedLayer.value && !selectedGroup.value))
|
||||
|
||||
// 对话框状态
|
||||
const dialogVisible = ref(false)
|
||||
const dialogIsEditing = ref(false)
|
||||
const dialogMode = ref<'binding' | 'conditional'>('binding')
|
||||
const dialogEditingBinding = ref<{
|
||||
id: string
|
||||
type: 'variable' | 'expression'
|
||||
value: string
|
||||
variables?: string[]
|
||||
priority: number
|
||||
enabled: boolean
|
||||
field: string
|
||||
} | null>(null)
|
||||
const dialogEditingConditional = ref<{
|
||||
id: string
|
||||
condition: string
|
||||
style: Record<string, any>
|
||||
priority: number
|
||||
enabled: boolean
|
||||
} | null>(null)
|
||||
|
||||
// 绑定字段选项
|
||||
const bindingFieldOptions = computed<BindingFieldOption[]>(() => {
|
||||
if (!selectedLayer.value)
|
||||
return []
|
||||
|
||||
const commonStyleFields: BindingFieldOption[] = [
|
||||
{ label: '边框颜色', value: 'style.border.color' },
|
||||
{ label: '边框宽度', value: 'style.border.width' },
|
||||
{ label: '圆角', value: 'style.border.radius' },
|
||||
{ label: '阴影颜色', value: 'style.shadow.color' },
|
||||
{ label: '阴影模糊', value: 'style.shadow.blur' },
|
||||
{ label: '阴影 X 偏移', value: 'style.shadow.offsetX' },
|
||||
{ label: '阴影 Y 偏移', value: 'style.shadow.offsetY' },
|
||||
]
|
||||
|
||||
const layerType = selectedLayer.value.type
|
||||
|
||||
if (layerType === 'rect') {
|
||||
return [
|
||||
{ label: '背景色', value: 'style.fill.color' },
|
||||
{ label: '背景透明度', value: 'style.fill.opacity' },
|
||||
...commonStyleFields,
|
||||
]
|
||||
}
|
||||
|
||||
if (layerType === 'bar') {
|
||||
return [
|
||||
{ label: '当前值', value: 'config.value' },
|
||||
{ label: '数值内容', value: 'config.valueContent' },
|
||||
{ label: '最小值', value: 'config.min' },
|
||||
{ label: '最大值', value: 'config.max' },
|
||||
{ label: '背景色', value: 'style.fill.color' },
|
||||
{ label: '背景透明度', value: 'style.fill.opacity' },
|
||||
...commonStyleFields,
|
||||
]
|
||||
}
|
||||
|
||||
if (layerType === 'button') {
|
||||
return [
|
||||
{ label: '按钮文本', value: 'config.label' },
|
||||
{ label: '文字颜色', value: 'style.text.color' },
|
||||
{ label: '背景色', value: 'style.fill.color' },
|
||||
{ label: '背景透明度', value: 'style.fill.opacity' },
|
||||
...commonStyleFields,
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: layerType === 'number' ? '数值' : '文本内容', value: 'value' },
|
||||
{ label: '文字颜色', value: 'style.text.color' },
|
||||
{ label: '背景色', value: 'style.fill.color' },
|
||||
{ label: '背景透明度', value: 'style.fill.opacity' },
|
||||
{ label: '字号', value: 'style.text.fontSize' },
|
||||
{ label: '字重', value: 'style.text.fontWeight' },
|
||||
...commonStyleFields,
|
||||
]
|
||||
})
|
||||
|
||||
// 如果图层已绑定位号,只展示该位号(模块)下的属性
|
||||
const cascaderOptions = computed(() => {
|
||||
const tagNumber = selectedLayer.value?.tagNumber
|
||||
if (!tagNumber)
|
||||
return variableOptions.value
|
||||
return variableOptions.value.filter(opt => opt.value === tagNumber)
|
||||
})
|
||||
|
||||
function getFieldLabel(field: string) {
|
||||
return bindingFieldOptions.value.find(o => o.value === field)?.label ?? field
|
||||
}
|
||||
|
||||
// 归一化绑定数据(stableId 用于 computed 中避免每次生成新 UUID)
|
||||
function normalizeBinding(binding: string | LayerBindingDefinition | undefined, stableId?: string): LayerBindingDefinition | null {
|
||||
if (!binding)
|
||||
return null
|
||||
if (typeof binding === 'string') {
|
||||
return {
|
||||
id: stableId || generateUUID(),
|
||||
type: 'variable',
|
||||
value: binding,
|
||||
variables: binding ? [binding] : undefined,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
if ((binding.type === 'variable' || binding.type === 'expression') && typeof binding.value === 'string') {
|
||||
return {
|
||||
...binding,
|
||||
id: binding.id || stableId || generateUUID(),
|
||||
priority: typeof binding.priority === 'number' && Number.isFinite(binding.priority) ? binding.priority : 0,
|
||||
enabled: binding.enabled !== false,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeBindingList(binding: LayerBindingValue | undefined, fieldKey?: string): LayerBindingDefinition[] {
|
||||
if (Array.isArray(binding)) {
|
||||
return binding.map((item, i) => normalizeBinding(item, fieldKey ? `${fieldKey}:${i}` : undefined)).filter(Boolean) as LayerBindingDefinition[]
|
||||
}
|
||||
const normalized = normalizeBinding(binding)
|
||||
return normalized ? [normalized] : []
|
||||
}
|
||||
|
||||
function inferExpressionVariables(expression: string) {
|
||||
const matches = expression.matchAll(VARIABLE_EXPRESSION_RE)
|
||||
return [...new Set(Array.from(matches, match => match[1]).filter(Boolean))]
|
||||
}
|
||||
|
||||
// 摘要列表
|
||||
const summaryItems = computed<SummaryItem[]>(() => {
|
||||
const items: SummaryItem[] = []
|
||||
const layer = selectedLayer.value
|
||||
if (!layer)
|
||||
return items
|
||||
|
||||
// 属性绑定
|
||||
if (layer.bindings) {
|
||||
for (const [field, binding] of Object.entries(layer.bindings)) {
|
||||
for (const def of normalizeBindingList(binding, field)) {
|
||||
items.push({
|
||||
id: def.id || field,
|
||||
kind: 'binding',
|
||||
icon: def.type === 'variable' ? 'fa-solid fa-bolt' : 'fa-solid fa-code',
|
||||
label: getFieldLabel(field),
|
||||
detail: def.type === 'variable'
|
||||
? def.value
|
||||
: (def.value.length > 30 ? `${def.value.slice(0, 30)}...` : def.value),
|
||||
enabled: def.enabled !== false,
|
||||
field,
|
||||
binding: def,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 条件样式
|
||||
if (layer.conditionalStyles) {
|
||||
for (const rule of layer.conditionalStyles) {
|
||||
const effects: string[] = []
|
||||
const bgColor = rule.style?.backgroundColor || rule.style?.fillColor
|
||||
if (bgColor)
|
||||
effects.push(`填充 ${bgColor}`)
|
||||
if (rule.style?.textColor)
|
||||
effects.push(`文字 ${rule.style.textColor}`)
|
||||
if (rule.style?.opacity != null)
|
||||
effects.push(`透明度 ${rule.style.opacity}`)
|
||||
if (rule.style?.borderColor)
|
||||
effects.push(`边框 ${rule.style.borderColor}`)
|
||||
if (rule.style?.visible === false)
|
||||
effects.push('隐藏')
|
||||
if (rule.style?.blink === true)
|
||||
effects.push('闪烁')
|
||||
items.push({
|
||||
id: rule.id,
|
||||
kind: 'conditional',
|
||||
icon: 'fa-solid fa-code-branch',
|
||||
label: rule.condition || '(空条件)',
|
||||
detail: effects.join(',') || '无样式变更',
|
||||
enabled: rule.enabled !== false,
|
||||
conditional: rule,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// 打开对话框
|
||||
function openAddDialog() {
|
||||
void ensureVariableCatalogLoaded(!variableOptions.value.length)
|
||||
dialogIsEditing.value = false
|
||||
dialogMode.value = 'binding'
|
||||
dialogEditingBinding.value = null
|
||||
dialogEditingConditional.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(item: SummaryItem) {
|
||||
void ensureVariableCatalogLoaded(!variableOptions.value.length)
|
||||
dialogIsEditing.value = true
|
||||
|
||||
if (item.kind === 'binding' && item.binding && item.field) {
|
||||
dialogMode.value = 'binding'
|
||||
dialogEditingBinding.value = {
|
||||
...item.binding,
|
||||
id: item.binding.id || item.id,
|
||||
field: item.field,
|
||||
priority: item.binding.priority ?? 0,
|
||||
enabled: item.binding.enabled !== false,
|
||||
}
|
||||
dialogEditingConditional.value = null
|
||||
}
|
||||
else if (item.kind === 'conditional' && item.conditional) {
|
||||
dialogMode.value = 'conditional'
|
||||
dialogEditingConditional.value = {
|
||||
id: item.conditional.id,
|
||||
condition: item.conditional.condition,
|
||||
style: { ...item.conditional.style },
|
||||
priority: item.conditional.priority ?? 0,
|
||||
enabled: item.conditional.enabled !== false,
|
||||
}
|
||||
dialogEditingBinding.value = null
|
||||
}
|
||||
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 保存绑定
|
||||
function onSaveBinding(data: {
|
||||
id: string
|
||||
field: string
|
||||
type: 'variable' | 'expression'
|
||||
value: string
|
||||
priority: number
|
||||
enabled: boolean
|
||||
}) {
|
||||
const layer = selectedLayer.value
|
||||
if (!layer)
|
||||
return
|
||||
|
||||
const bindings: Record<string, LayerBindingDefinition[]> = {}
|
||||
|
||||
// 归一化现有绑定
|
||||
if (layer.bindings) {
|
||||
for (const [field, binding] of Object.entries(layer.bindings)) {
|
||||
const list = normalizeBindingList(binding)
|
||||
if (list.length)
|
||||
bindings[field] = list
|
||||
}
|
||||
}
|
||||
|
||||
// 从所有字段中移除该 ID(处理字段变更)
|
||||
for (const [field, list] of Object.entries(bindings)) {
|
||||
bindings[field] = list.filter(b => b.id !== data.id)
|
||||
if (!bindings[field].length)
|
||||
delete bindings[field]
|
||||
}
|
||||
|
||||
// 添加到目标字段
|
||||
if (!bindings[data.field])
|
||||
bindings[data.field] = []
|
||||
|
||||
bindings[data.field].push({
|
||||
id: data.id,
|
||||
type: data.type,
|
||||
value: data.value,
|
||||
priority: data.priority,
|
||||
enabled: data.enabled,
|
||||
variables: data.type === 'expression'
|
||||
? inferExpressionVariables(data.value)
|
||||
: data.value
|
||||
? [data.value]
|
||||
: undefined,
|
||||
})
|
||||
|
||||
setEditorEmitter('layer:updateField', {
|
||||
id: layer.id,
|
||||
field: 'bindings',
|
||||
value: Object.keys(bindings).length ? bindings : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 保存条件样式
|
||||
function onSaveConditional(data: {
|
||||
id: string
|
||||
condition: string
|
||||
style: Record<string, any>
|
||||
priority: number
|
||||
enabled: boolean
|
||||
}) {
|
||||
const layer = selectedLayer.value
|
||||
if (!layer)
|
||||
return
|
||||
|
||||
const rules = [...(layer.conditionalStyles ?? [])]
|
||||
const existingIndex = rules.findIndex(r => r.id === data.id)
|
||||
|
||||
const newRule = {
|
||||
id: data.id,
|
||||
condition: data.condition,
|
||||
style: data.style,
|
||||
priority: data.priority,
|
||||
enabled: data.enabled,
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
rules[existingIndex] = newRule
|
||||
}
|
||||
else {
|
||||
rules.push(newRule)
|
||||
}
|
||||
|
||||
setEditorEmitter('layer:updateField', {
|
||||
id: layer.id,
|
||||
field: 'conditionalStyles',
|
||||
value: rules.length ? rules : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 切换启用/禁用
|
||||
function toggleItem(item: SummaryItem) {
|
||||
const nextEnabled = !item.enabled
|
||||
|
||||
if (item.kind === 'binding' && item.binding && item.field) {
|
||||
const layer = selectedLayer.value
|
||||
if (!layer)
|
||||
return
|
||||
|
||||
const bindings: Record<string, LayerBindingDefinition[]> = {}
|
||||
if (layer.bindings) {
|
||||
for (const [field, binding] of Object.entries(layer.bindings)) {
|
||||
const list = normalizeBindingList(binding).map((b) => {
|
||||
if (b.id === item.id) {
|
||||
return { ...b, enabled: nextEnabled }
|
||||
}
|
||||
return b
|
||||
})
|
||||
if (list.length)
|
||||
bindings[field] = list
|
||||
}
|
||||
}
|
||||
|
||||
setEditorEmitter('layer:updateField', {
|
||||
id: layer.id,
|
||||
field: 'bindings',
|
||||
value: Object.keys(bindings).length ? bindings : undefined,
|
||||
})
|
||||
}
|
||||
else if (item.kind === 'conditional') {
|
||||
const layer = selectedLayer.value
|
||||
if (!layer)
|
||||
return
|
||||
|
||||
const rules = (layer.conditionalStyles ?? []).map((r) => {
|
||||
if (r.id === item.id) {
|
||||
return { ...r, enabled: nextEnabled }
|
||||
}
|
||||
return r
|
||||
})
|
||||
|
||||
setEditorEmitter('layer:updateField', {
|
||||
id: layer.id,
|
||||
field: 'conditionalStyles',
|
||||
value: rules.length ? rules : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
function deleteItem(item: SummaryItem) {
|
||||
const layer = selectedLayer.value
|
||||
if (!layer)
|
||||
return
|
||||
|
||||
if (item.kind === 'binding') {
|
||||
const bindings: Record<string, LayerBindingDefinition[]> = {}
|
||||
if (layer.bindings) {
|
||||
for (const [field, binding] of Object.entries(layer.bindings)) {
|
||||
const filtered = normalizeBindingList(binding).filter(b => b.id !== item.id)
|
||||
if (filtered.length)
|
||||
bindings[field] = filtered
|
||||
}
|
||||
}
|
||||
|
||||
setEditorEmitter('layer:updateField', {
|
||||
id: layer.id,
|
||||
field: 'bindings',
|
||||
value: Object.keys(bindings).length ? bindings : undefined,
|
||||
})
|
||||
}
|
||||
else if (item.kind === 'conditional') {
|
||||
const rules = (layer.conditionalStyles ?? []).filter(r => r.id !== item.id)
|
||||
setEditorEmitter('layer:updateField', {
|
||||
id: layer.id,
|
||||
field: 'conditionalStyles',
|
||||
value: rules.length ? rules : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection v-if="canEdit" title="数据绑定">
|
||||
<template #actions>
|
||||
<EdIconButton icon="fa-solid fa-plus" title="添加绑定" @click="openAddDialog" />
|
||||
</template>
|
||||
|
||||
<!-- 绑定摘要列表 -->
|
||||
<div v-if="summaryItems.length" class="binding-list">
|
||||
<div
|
||||
v-for="item in summaryItems"
|
||||
:key="item.id"
|
||||
class="binding-item"
|
||||
:class="{ 'binding-item--disabled': !item.enabled }"
|
||||
>
|
||||
<i class="binding-item__icon" :class="item.icon" />
|
||||
<div class="binding-item__info">
|
||||
<span class="binding-item__label">{{ item.label }}</span>
|
||||
<span class="binding-item__detail">{{ item.detail }}</span>
|
||||
</div>
|
||||
<div class="binding-item__actions">
|
||||
<el-switch
|
||||
:model-value="item.enabled"
|
||||
size="small"
|
||||
@update:model-value="toggleItem(item)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="binding-item__btn"
|
||||
title="编辑"
|
||||
@click="openEditDialog(item)"
|
||||
>
|
||||
<i class="fa-solid fa-pen-to-square" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="binding-item__btn binding-item__btn--danger"
|
||||
title="删除"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<i class="fa-solid fa-trash-can" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="binding-empty">
|
||||
暂无绑定。点击 + 添加变量绑定或条件样式。
|
||||
</div>
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<BindingConfigDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:is-editing="dialogIsEditing"
|
||||
:mode="dialogMode"
|
||||
:field-options="bindingFieldOptions"
|
||||
:cascader-options="cascaderOptions"
|
||||
:is-loading-variables="isLoadingVariables"
|
||||
:variable-load-error="variableLoadError"
|
||||
:editing-binding="dialogEditingBinding"
|
||||
:editing-conditional="dialogEditingConditional"
|
||||
@save-binding="onSaveBinding"
|
||||
@save-conditional="onSaveConditional"
|
||||
@retry-load="ensureVariableCatalogLoaded(true)"
|
||||
/>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.binding-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.binding-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--ed-border);
|
||||
background: var(--ed-bg-surface);
|
||||
transition: border-color 0.15s, opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ed-accent);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.binding-item__icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--ed-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.binding-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.binding-item__label {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.binding-item__detail {
|
||||
font-size: 11px;
|
||||
color: var(--ed-text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.binding-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.binding-item__btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ed-text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--ed-bg-hover);
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.binding-empty {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-disabled);
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdColorInput, EdNumberInput, EdSection, EdSegmented, EdSelect } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
import GradientEditor from './GradientEditor.vue'
|
||||
|
||||
const { selectedLayer } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const DEFAULT_RECT_FILL = '#B7B7B7'
|
||||
|
||||
const fillColorFallback = computed(() => {
|
||||
if (selectedLayer.value?.type === 'rect') {
|
||||
return DEFAULT_RECT_FILL
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 棒图只支持纯色填充,不提供渐变选项。
|
||||
const solidOnly = computed(() => selectedLayer.value?.type === 'bar')
|
||||
|
||||
const fillColor = computed(() => {
|
||||
return String(selectedLayer.value?.config?.fillColor ?? fillColorFallback.value)
|
||||
})
|
||||
|
||||
const fillOpacity = computed(() => {
|
||||
const raw = selectedLayer.value?.config?.fillOpacity
|
||||
if (typeof raw === 'number') {
|
||||
return raw
|
||||
}
|
||||
return 100
|
||||
})
|
||||
|
||||
// 填充类型:solid | gradient | image
|
||||
// 优先使用显式标记,兼容无标记的旧数据
|
||||
const fillType = computed(() => {
|
||||
const active = selectedLayer.value?.style?.fill?.activeType
|
||||
if (active)
|
||||
return active === 'linear' || active === 'radial' ? 'gradient' : active
|
||||
const image = selectedLayer.value?.style?.fill?.image
|
||||
if (image && 'src' in image)
|
||||
return 'image'
|
||||
const gradient = selectedLayer.value?.style?.fill?.gradient
|
||||
if (gradient?.type)
|
||||
return 'gradient'
|
||||
return 'solid'
|
||||
})
|
||||
|
||||
const fillTypeOptions = [
|
||||
{ label: '纯色', value: 'solid' },
|
||||
{ label: '渐变', value: 'gradient' },
|
||||
{ label: '图片', value: 'image' },
|
||||
]
|
||||
|
||||
function onFillTypeChange(type: string) {
|
||||
updateField('style.fill.activeType', type)
|
||||
if (type === 'image') {
|
||||
// 没有已有图片数据时才初始化
|
||||
if (!selectedLayer.value?.style?.fill?.image) {
|
||||
updateField('style.fill.image', { src: '', fit: 'cover', opacity: 1 })
|
||||
}
|
||||
}
|
||||
else if (type === 'gradient') {
|
||||
// 没有已有渐变数据时才初始化
|
||||
if (!selectedLayer.value?.style?.fill?.gradient?.type) {
|
||||
updateField('style.fill.gradient', {
|
||||
type: 'linear',
|
||||
colors: ['#ffffff', '#000000'],
|
||||
stops: [0, 1],
|
||||
angle: 180,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateGradientField(subField: string, value: unknown) {
|
||||
updateField(`style.fill.gradient.${subField}`, value)
|
||||
}
|
||||
|
||||
// 图片填充
|
||||
const fillImage = computed(() => selectedLayer.value?.style?.fill?.image)
|
||||
const fillImageFit = computed(() => fillImage.value?.fit ?? 'cover')
|
||||
const fillImageOpacity = computed(() => Math.round((fillImage.value?.opacity ?? 1) * 100))
|
||||
|
||||
const imageFitOptions = [
|
||||
{ label: '覆盖', value: 'cover' },
|
||||
{ label: '包含', value: 'contain' },
|
||||
{ label: '拉伸', value: 'fill' },
|
||||
]
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
|
||||
function onImageUpload(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
import('element-plus').then(({ ElMessage }) => {
|
||||
ElMessage.warning('图片大小不能超过 2MB')
|
||||
})
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
updateField('style.fill.image.src', e.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="填充">
|
||||
<div v-if="!solidOnly" class="fill-type-switch">
|
||||
<EdSegmented :model-value="fillType" :options="fillTypeOptions" @update:model-value="onFillTypeChange" />
|
||||
</div>
|
||||
|
||||
<!-- 纯色模式 -->
|
||||
<template v-if="solidOnly || fillType === 'solid'">
|
||||
<div class="fill-row">
|
||||
<EdColorInput
|
||||
:model-value="fillColor"
|
||||
placeholder="无"
|
||||
@update:model-value="updateField('config.fillColor', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
:model-value="fillOpacity"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
label="%"
|
||||
@update:model-value="updateField('config.fillOpacity', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 渐变模式 -->
|
||||
<template v-else-if="fillType === 'gradient'">
|
||||
<GradientEditor
|
||||
:type="selectedLayer?.style?.fill?.gradient?.type ?? 'linear'"
|
||||
:colors="selectedLayer?.style?.fill?.gradient?.colors ?? ['#ffffff', '#000000']"
|
||||
:stops="selectedLayer?.style?.fill?.gradient?.stops ?? [0, 1]"
|
||||
:angle="selectedLayer?.style?.fill?.gradient?.angle ?? 180"
|
||||
@update:type="v => updateGradientField('type', v)"
|
||||
@update:colors="v => updateGradientField('colors', v)"
|
||||
@update:stops="v => updateGradientField('stops', v)"
|
||||
@update:angle="v => updateGradientField('angle', v)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 图片填充模式 -->
|
||||
<template v-else-if="fillType === 'image'">
|
||||
<div class="fill-image-area">
|
||||
<div class="fill-image-preview">
|
||||
<img v-if="fillImage?.src" :src="fillImage.src" class="fill-image-thumb">
|
||||
<label v-else class="fill-image-upload">
|
||||
<i class="fa-solid fa-image" />
|
||||
<span>选择图片</span>
|
||||
<input type="file" accept="image/*" hidden @change="onImageUpload">
|
||||
</label>
|
||||
<label v-if="fillImage?.src" class="fill-image-change">
|
||||
<span>更换</span>
|
||||
<input type="file" accept="image/*" hidden @change="onImageUpload">
|
||||
</label>
|
||||
</div>
|
||||
<div class="fill-image-options">
|
||||
<EdSelect
|
||||
:model-value="fillImageFit"
|
||||
:options="imageFitOptions"
|
||||
@update:model-value="updateField('style.fill.image.fit', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
:model-value="fillImageOpacity"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
label="%"
|
||||
@update:model-value="updateField('style.fill.image.opacity', $event / 100)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fill-type-switch {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fill-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
|
||||
.fill-image-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
|
||||
.fill-image-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fill-image-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.fill-image-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fill-image-change {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--ed-accent, #1677ff);
|
||||
}
|
||||
|
||||
.fill-image-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdNumberInput, EdSection } from '../../../../controls'
|
||||
import { useCanvasRuntimeInject } from '../../../context'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer, selectedGroup } = useCanvasState()
|
||||
const { variableMap, isLoadingVariables, variableLoadError, ensureVariableCatalogLoaded } = useCanvasRuntimeInject()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
// 业务组件才显示位号选择,基础组件(rect/number/text/button)不需要
|
||||
const BUSINESS_TYPES = new Set(['bar', 'pidController', 'valveController', 'canvasSwitcher'])
|
||||
const showTagNumber = computed(() =>
|
||||
!selectedGroup.value && !!selectedLayer.value && BUSINESS_TYPES.has(selectedLayer.value.type),
|
||||
)
|
||||
|
||||
// 属性面板挂载时确保变量目录已加载,否则位号下拉为空。
|
||||
ensureVariableCatalogLoaded()
|
||||
|
||||
function isAllowedDevice(moduleLabel: string, componentType: string | undefined): boolean {
|
||||
if (componentType === 'bar') {
|
||||
return moduleLabel === 'Vessel'
|
||||
|| moduleLabel.startsWith('Tank_')
|
||||
|| moduleLabel.startsWith('Dtower')
|
||||
|| moduleLabel.startsWith('RCSTR')
|
||||
|| moduleLabel.startsWith('FlashTank')
|
||||
}
|
||||
if (componentType === 'pidController') {
|
||||
return moduleLabel === 'PID_M'
|
||||
|| moduleLabel === 'PID'
|
||||
|| moduleLabel === 'SelfTuning_PID'
|
||||
}
|
||||
if (componentType === 'valveController') {
|
||||
return moduleLabel.includes('Valve')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 从变量目录提取位号候选(只取模块名,不含属性)
|
||||
const tagNumberOptions = computed<string[]>(() => {
|
||||
if (!variableMap.value)
|
||||
return []
|
||||
const componentType = selectedLayer.value?.type
|
||||
return [...new Set(
|
||||
Object.values(variableMap.value)
|
||||
.filter(v => isAllowedDevice(v.moduleLabel, componentType))
|
||||
.map(v => v.moduleName),
|
||||
)]
|
||||
})
|
||||
|
||||
function onTagNumberChange(val: string) {
|
||||
updateField('tagNumber', val || undefined)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="位置与尺寸">
|
||||
<div class="geometry-grid">
|
||||
<EdNumberInput
|
||||
label="X"
|
||||
:model-value="selectedGroup?.x ?? selectedLayer?.x ?? 0"
|
||||
:step="1"
|
||||
@update:model-value="updateField('x', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="Y"
|
||||
:model-value="selectedGroup?.y ?? selectedLayer?.y ?? 0"
|
||||
:step="1"
|
||||
@update:model-value="updateField('y', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="W"
|
||||
:model-value="selectedGroup?.width ?? selectedLayer?.width ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
@update:model-value="updateField('width', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="H"
|
||||
:model-value="selectedGroup?.height ?? selectedLayer?.height ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
@update:model-value="updateField('height', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="°"
|
||||
:model-value="selectedLayer?.config?.rotate ?? 0"
|
||||
:min="0"
|
||||
:max="360"
|
||||
:step="1"
|
||||
class="geo-full"
|
||||
@update:model-value="updateField('config.rotate', $event)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 设备位号(仅业务组件显示) -->
|
||||
<div v-if="showTagNumber" class="tag-number-row">
|
||||
<span class="tag-label">设备位号</span>
|
||||
<el-select
|
||||
:model-value="selectedLayer?.tagNumber || ''"
|
||||
:loading="isLoadingVariables"
|
||||
filterable
|
||||
allow-create
|
||||
clearable
|
||||
size="small"
|
||||
:placeholder="variableLoadError ? '未关联项目' : '如 FT-101'"
|
||||
class="tag-select"
|
||||
@update:model-value="onTagNumberChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="tag in tagNumberOptions"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
:value="tag"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.geometry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
|
||||
.geo-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.tag-number-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tag-label {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-secondary, #606266);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 覆盖 el-select 样式使其匹配编辑器风格 */
|
||||
.tag-select :deep(.el-input__wrapper) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: var(--ed-radius-sm, 4px);
|
||||
height: var(--ed-control-height, 24px);
|
||||
padding: 0 6px;
|
||||
font-size: var(--ed-font-md, 12px);
|
||||
transition: background var(--ed-transition-fast, 0.15s), border-color var(--ed-transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.tag-select :deep(.el-input__wrapper:hover) {
|
||||
background: var(--ed-bg-hover, #f5f5f5);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tag-select :deep(.el-input__wrapper.is-focus) {
|
||||
background: var(--ed-bg-raised, #fafafa);
|
||||
box-shadow: none;
|
||||
border-bottom-color: var(--ed-accent, #1677ff);
|
||||
}
|
||||
|
||||
.tag-select :deep(.el-input__inner) {
|
||||
font-family: var(--ed-font);
|
||||
font-size: var(--ed-font-md, 12px);
|
||||
color: var(--ed-text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.tag-select :deep(.el-input__inner::placeholder) {
|
||||
color: var(--ed-text-tertiary, #8c8c8c);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdInput, EdNumberInput, EdSection } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const prefix = computed(() => String(selectedLayer.value?.config?.prefix ?? ''))
|
||||
const suffix = computed(() => String(selectedLayer.value?.config?.suffix ?? ''))
|
||||
const decimals = computed(() => Number(selectedLayer.value?.config?.decimals ?? 2))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="数值格式">
|
||||
<div class="format-grid">
|
||||
<EdInput
|
||||
:model-value="prefix"
|
||||
placeholder="前缀"
|
||||
@update:model-value="updateField('config.prefix', $event)"
|
||||
/>
|
||||
<EdInput
|
||||
:model-value="suffix"
|
||||
placeholder="后缀"
|
||||
@update:model-value="updateField('config.suffix', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="D"
|
||||
:model-value="decimals"
|
||||
:min="0"
|
||||
:max="10"
|
||||
:step="1"
|
||||
@update:model-value="updateField('config.decimals', $event)"
|
||||
/>
|
||||
</div>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
|
||||
.format-grid > :last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdColorInput, EdNumberInput, EdSection, EdToggle } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const shadowEnabled = computed(() => Boolean(selectedLayer.value?.style?.shadow?.enabled))
|
||||
const shadowColor = computed(() => String(selectedLayer.value?.style?.shadow?.color ?? ''))
|
||||
const shadowBlur = computed(() => Number(selectedLayer.value?.style?.shadow?.blur ?? 0))
|
||||
const shadowOffsetX = computed(() => Number(selectedLayer.value?.style?.shadow?.offsetX ?? 0))
|
||||
const shadowOffsetY = computed(() => Number(selectedLayer.value?.style?.shadow?.offsetY ?? 0))
|
||||
const shadowOpacity = computed(() => {
|
||||
const raw = selectedLayer.value?.style?.shadow?.opacity
|
||||
return typeof raw === 'number' ? Math.round(raw * 100) : 100
|
||||
})
|
||||
const shadowInset = computed(() => Boolean(selectedLayer.value?.style?.shadow?.inset))
|
||||
|
||||
const supportsTextShadow = computed(() => {
|
||||
const type = selectedLayer.value?.type
|
||||
return type === 'text' || type === 'number'
|
||||
})
|
||||
const textShadowEnabled = computed(() => Boolean(selectedLayer.value?.style?.textShadow?.enabled))
|
||||
const textShadowColor = computed(() => String(selectedLayer.value?.style?.textShadow?.color ?? ''))
|
||||
const textShadowBlur = computed(() => Number(selectedLayer.value?.style?.textShadow?.blur ?? 0))
|
||||
const textShadowOffsetX = computed(() => Number(selectedLayer.value?.style?.textShadow?.offsetX ?? 0))
|
||||
const textShadowOffsetY = computed(() => Number(selectedLayer.value?.style?.textShadow?.offsetY ?? 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<EdSection title="阴影" :default-collapsed="true">
|
||||
<template #actions>
|
||||
<EdToggle
|
||||
:model-value="shadowEnabled"
|
||||
@update:model-value="updateField('style.shadow.enabled', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="shadowEnabled" class="shadow-grid">
|
||||
<EdColorInput
|
||||
:model-value="shadowColor"
|
||||
placeholder="无"
|
||||
@update:model-value="updateField('style.shadow.color', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="B"
|
||||
:model-value="shadowBlur"
|
||||
:min="0"
|
||||
:step="1"
|
||||
@update:model-value="updateField('style.shadow.blur', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="X"
|
||||
:model-value="shadowOffsetX"
|
||||
:step="1"
|
||||
@update:model-value="updateField('style.shadow.offsetX', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="Y"
|
||||
:model-value="shadowOffsetY"
|
||||
:step="1"
|
||||
@update:model-value="updateField('style.shadow.offsetY', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
:model-value="shadowOpacity"
|
||||
:min="0" :max="100" :step="1"
|
||||
label="%"
|
||||
@update:model-value="updateField('style.shadow.opacity', $event / 100)"
|
||||
/>
|
||||
<div class="shadow-inset-row">
|
||||
<span class="shadow-label">内阴影</span>
|
||||
<EdToggle
|
||||
:model-value="shadowInset"
|
||||
@update:model-value="updateField('style.shadow.inset', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EdSection>
|
||||
|
||||
<EdSection v-if="supportsTextShadow" title="文本阴影" :default-collapsed="true">
|
||||
<template #actions>
|
||||
<EdToggle
|
||||
:model-value="textShadowEnabled"
|
||||
@update:model-value="updateField('style.textShadow.enabled', $event)"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="textShadowEnabled" class="shadow-grid">
|
||||
<EdColorInput
|
||||
:model-value="textShadowColor"
|
||||
placeholder="无"
|
||||
@update:model-value="updateField('style.textShadow.color', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="B" :model-value="textShadowBlur" :min="0" :step="1"
|
||||
@update:model-value="updateField('style.textShadow.blur', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="X" :model-value="textShadowOffsetX" :step="1"
|
||||
@update:model-value="updateField('style.textShadow.offsetX', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="Y" :model-value="textShadowOffsetY" :step="1"
|
||||
@update:model-value="updateField('style.textShadow.offsetY', $event)"
|
||||
/>
|
||||
</div>
|
||||
</EdSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shadow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
.shadow-inset-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.shadow-label {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-secondary, #606266);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { EdSelectOption } from '../../../../controls'
|
||||
import { computed } from 'vue'
|
||||
import { EdColorInput, EdNumberInput, EdSection, EdSelect } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const borderColor = computed(() => String(selectedLayer.value?.style?.border?.color ?? ''))
|
||||
const borderWidth = computed(() => Number(selectedLayer.value?.style?.border?.width ?? 0))
|
||||
const borderStyle = computed(() => String(selectedLayer.value?.style?.border?.style ?? 'solid'))
|
||||
const borderRadius = computed(() => Number(selectedLayer.value?.config?.radius ?? 0))
|
||||
|
||||
const styleOptions: EdSelectOption[] = [
|
||||
{ label: '实线', value: 'solid' },
|
||||
{ label: '虚线', value: 'dashed' },
|
||||
{ label: '点线', value: 'dotted' },
|
||||
{ label: '无', value: 'none' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="边框" :default-collapsed="true">
|
||||
<div class="stroke-grid">
|
||||
<EdColorInput
|
||||
:model-value="borderColor"
|
||||
placeholder="无"
|
||||
@update:model-value="updateField('style.border.color', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="W"
|
||||
:model-value="borderWidth"
|
||||
:min="0"
|
||||
:step="1"
|
||||
@update:model-value="updateField('style.border.width', $event)"
|
||||
/>
|
||||
<EdSelect
|
||||
:model-value="borderStyle"
|
||||
:options="styleOptions"
|
||||
@update:model-value="updateField('style.border.style', $event)"
|
||||
/>
|
||||
<EdNumberInput
|
||||
label="R"
|
||||
:model-value="borderRadius"
|
||||
:min="0"
|
||||
:step="1"
|
||||
@update:model-value="updateField('style.border.radius', $event)"
|
||||
/>
|
||||
</div>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stroke-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import type { EdSelectOption } from '../../../../controls'
|
||||
import { computed } from 'vue'
|
||||
import { EdColorInput, EdIconButton, EdNumberInput, EdSection, EdSelect } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const isNumber = computed(() => selectedLayer.value?.type === 'number')
|
||||
|
||||
const textColor = computed(() => String(selectedLayer.value?.config?.textColor ?? ''))
|
||||
const fontSize = computed(() => Number(selectedLayer.value?.config?.fontSize ?? (isNumber.value ? 24 : 18)))
|
||||
const fontWeight = computed(() => Number(selectedLayer.value?.config?.fontWeight ?? (isNumber.value ? 700 : 500)))
|
||||
const fontStyle = computed(() => String(selectedLayer.value?.config?.fontStyle ?? 'normal'))
|
||||
const textDecoration = computed(() => String(selectedLayer.value?.config?.textDecoration ?? 'none'))
|
||||
const textAlign = computed(() => String(selectedLayer.value?.config?.align ?? 'center'))
|
||||
const verticalAlign = computed(() => String(selectedLayer.value?.config?.verticalAlign ?? 'middle'))
|
||||
|
||||
const fontWeightOptions: EdSelectOption[] = [
|
||||
{ label: '细体', value: 300 },
|
||||
{ label: '常规', value: 400 },
|
||||
{ label: '中等', value: 500 },
|
||||
{ label: '半粗', value: 600 },
|
||||
{ label: '粗体', value: 700 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="文字">
|
||||
<div class="text-grid">
|
||||
<div class="text-color-row">
|
||||
<EdColorInput
|
||||
:model-value="textColor"
|
||||
placeholder="自动"
|
||||
@update:model-value="updateField('config.textColor', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-detail-row">
|
||||
<EdNumberInput
|
||||
label="A"
|
||||
:model-value="fontSize"
|
||||
:min="10"
|
||||
:step="1"
|
||||
@update:model-value="updateField('config.fontSize', $event)"
|
||||
/>
|
||||
<EdSelect
|
||||
:model-value="fontWeight"
|
||||
:options="fontWeightOptions"
|
||||
@update:model-value="updateField('config.fontWeight', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-style-row">
|
||||
<EdIconButton
|
||||
icon="fa-solid fa-italic"
|
||||
:active="fontStyle === 'italic'"
|
||||
title="斜体"
|
||||
@click="updateField('config.fontStyle', fontStyle === 'italic' ? 'normal' : 'italic')"
|
||||
/>
|
||||
<EdIconButton
|
||||
icon="fa-solid fa-underline"
|
||||
:active="textDecoration === 'underline'"
|
||||
title="下划线"
|
||||
@click="updateField('config.textDecoration', textDecoration === 'underline' ? 'none' : 'underline')"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-align-row">
|
||||
<div class="align-group">
|
||||
<EdIconButton
|
||||
icon="fa-solid fa-align-left"
|
||||
:active="textAlign === 'left'"
|
||||
title="左对齐"
|
||||
@click="updateField('config.align', 'left')"
|
||||
/>
|
||||
<EdIconButton
|
||||
icon="fa-solid fa-align-center"
|
||||
:active="textAlign === 'center'"
|
||||
title="居中"
|
||||
@click="updateField('config.align', 'center')"
|
||||
/>
|
||||
<EdIconButton
|
||||
icon="fa-solid fa-align-right"
|
||||
:active="textAlign === 'right'"
|
||||
title="右对齐"
|
||||
@click="updateField('config.align', 'right')"
|
||||
/>
|
||||
</div>
|
||||
<div class="align-group">
|
||||
<EdIconButton
|
||||
icon="fa-solid fa-arrows-up-to-line"
|
||||
:active="verticalAlign === 'top'"
|
||||
title="顶部对齐"
|
||||
@click="updateField('config.verticalAlign', 'top')"
|
||||
/>
|
||||
<EdIconButton
|
||||
icon="fa-solid fa-arrows-left-right-to-line fa-rotate-90"
|
||||
:active="verticalAlign === 'middle'"
|
||||
title="垂直居中"
|
||||
@click="updateField('config.verticalAlign', 'middle')"
|
||||
/>
|
||||
<EdIconButton
|
||||
icon="fa-solid fa-arrows-down-to-line"
|
||||
:active="verticalAlign === 'bottom'"
|
||||
title="底部对齐"
|
||||
@click="updateField('config.verticalAlign', 'bottom')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
|
||||
.text-color-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--ed-space-4);
|
||||
}
|
||||
|
||||
.text-style-row {
|
||||
display: flex;
|
||||
gap: var(--ed-space-1);
|
||||
}
|
||||
|
||||
.text-align-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.align-group {
|
||||
display: flex;
|
||||
gap: var(--ed-space-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdInput, EdSection } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const content = computed(() => String(selectedLayer.value?.config?.content ?? ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="文本内容">
|
||||
<EdInput
|
||||
:model-value="content"
|
||||
placeholder="输入文本内容"
|
||||
@update:model-value="updateField('config.content', $event)"
|
||||
/>
|
||||
</EdSection>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EdColorInput, EdInput, EdNumberInput, EdSection } from '../../../../controls'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { usePropertyContext } from '../composables/usePropertyContext'
|
||||
|
||||
const { selectedLayer } = useCanvasState()
|
||||
const { updateField } = usePropertyContext()
|
||||
|
||||
const bgColor = computed(() => String(selectedLayer.value?.config?.bgColor ?? '#1a1a2e'))
|
||||
const textColor = computed(() => String(selectedLayer.value?.config?.textColor ?? '#ffffff'))
|
||||
const decimalPlaces = computed(() => Number(selectedLayer.value?.config?.decimalPlaces ?? 2))
|
||||
const description = computed(() => String(selectedLayer.value?.config?.description ?? ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EdSection title="阀门控制器">
|
||||
<div class="valve-config-grid">
|
||||
<div class="valve-config-item">
|
||||
<span class="valve-config-label">描述</span>
|
||||
<EdInput
|
||||
:model-value="description"
|
||||
placeholder="设备描述"
|
||||
@update:model-value="updateField('config.description', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="valve-config-item">
|
||||
<span class="valve-config-label">小数位数</span>
|
||||
<EdNumberInput
|
||||
:model-value="decimalPlaces"
|
||||
:min="0"
|
||||
:max="6"
|
||||
:step="1"
|
||||
@update:model-value="updateField('config.decimalPlaces', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="valve-config-item">
|
||||
<span class="valve-config-label">背景色</span>
|
||||
<EdColorInput :model-value="bgColor" @update:model-value="updateField('config.bgColor', $event)" />
|
||||
</div>
|
||||
<div class="valve-config-item">
|
||||
<span class="valve-config-label">文字色</span>
|
||||
<EdColorInput :model-value="textColor" @update:model-value="updateField('config.textColor', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</EdSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.valve-config-grid { display: flex; flex-direction: column; gap: var(--ed-space-4); }
|
||||
.valve-config-item { display: flex; align-items: center; gap: 8px; }
|
||||
.valve-config-label { font-size: 12px; color: var(--ed-text-secondary, #606266); min-width: 48px; flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, shallowRef, watch } from 'vue'
|
||||
|
||||
export interface ContextMenuItem {
|
||||
key: string
|
||||
label?: string
|
||||
type?: 'item' | 'separator'
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
checked?: boolean
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
items: ContextMenuItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
select: [key: string]
|
||||
}>()
|
||||
|
||||
const menuRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
function onSelect(key: string, disabled?: boolean) {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
emit('select', key)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function isEventInsideMenu(event: Event) {
|
||||
const menuElement = menuRef.value
|
||||
if (!menuElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
return event.composedPath().includes(menuElement)
|
||||
}
|
||||
|
||||
function onWindowPointerDown(event: PointerEvent) {
|
||||
if (isEventInsideMenu(event)) {
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onWindowContextMenu(event: MouseEvent) {
|
||||
if (isEventInsideMenu(event)) {
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
window.addEventListener('pointerdown', onWindowPointerDown, true)
|
||||
window.addEventListener('contextmenu', onWindowContextMenu, true)
|
||||
window.addEventListener('keydown', onWindowKeydown)
|
||||
return
|
||||
}
|
||||
|
||||
window.removeEventListener('pointerdown', onWindowPointerDown, true)
|
||||
window.removeEventListener('contextmenu', onWindowContextMenu, true)
|
||||
window.removeEventListener('keydown', onWindowKeydown)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointerdown', onWindowPointerDown, true)
|
||||
window.removeEventListener('contextmenu', onWindowContextMenu, true)
|
||||
window.removeEventListener('keydown', onWindowKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="editor-context-menu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
@pointerdown.stop
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<template v-for="item in items" :key="item.key">
|
||||
<div
|
||||
v-if="item.type === 'separator'"
|
||||
class="context-menu-separator"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="context-menu-item"
|
||||
:class="{ danger: item.danger, disabled: item.disabled, checked: item.checked }"
|
||||
:disabled="item.disabled"
|
||||
@click.stop="onSelect(item.key, item.disabled)"
|
||||
>
|
||||
<span class="context-menu-item__label">
|
||||
<i v-if="item.checked" class="fa-solid fa-check context-menu-item__check" />
|
||||
<span>{{ item.label }}</span>
|
||||
</span>
|
||||
<span v-if="item.shortcut" class="context-menu-item__shortcut">{{ item.shortcut }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-context-menu {
|
||||
position: fixed;
|
||||
min-width: 172px;
|
||||
max-width: 196px;
|
||||
max-height: min(400px, 80vh);
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border-radius: var(--ed-radius-lg);
|
||||
border: 1px solid var(--ed-border);
|
||||
background: var(--ed-bg-raised);
|
||||
box-shadow: var(--ed-shadow-md);
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.context-menu-separator {
|
||||
height: 1px;
|
||||
margin: 4px 8px;
|
||||
background: var(--ed-border);
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: var(--ed-radius-md);
|
||||
background: transparent;
|
||||
color: var(--ed-text-primary);
|
||||
font-size: var(--ed-font-sm);
|
||||
font-weight: var(--ed-font-weight-normal);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color var(--ed-transition-fast), color var(--ed-transition-fast);
|
||||
}
|
||||
|
||||
.context-menu-item:hover:not(.disabled) {
|
||||
background: var(--ed-bg-hover);
|
||||
color: var(--ed-text-primary);
|
||||
}
|
||||
|
||||
.context-menu-item__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.context-menu-item__check {
|
||||
font-size: 11px;
|
||||
color: var(--ed-accent);
|
||||
}
|
||||
|
||||
.context-menu-item__shortcut {
|
||||
margin-left: 12px;
|
||||
font-size: var(--ed-font-xs);
|
||||
color: var(--ed-text-tertiary);
|
||||
font-weight: var(--ed-font-weight-normal);
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: var(--ed-danger);
|
||||
}
|
||||
|
||||
.context-menu-item.danger:hover:not(.disabled) {
|
||||
background: rgba(245, 34, 45, 0.06);
|
||||
color: var(--ed-danger);
|
||||
}
|
||||
|
||||
.context-menu-item.disabled {
|
||||
color: var(--ed-text-disabled);
|
||||
cursor: not-allowed;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.context-menu-item.disabled .context-menu-item__shortcut {
|
||||
color: var(--ed-text-disabled);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,703 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, shallowRef, watch } from 'vue'
|
||||
import { getCanvasByIdApi } from '@/api'
|
||||
import { MAX_CANVAS_SIZE, MIN_CANVAS_HEIGHT, MIN_CANVAS_WIDTH } from '@/constants'
|
||||
import pinia, { useCanvasStore, useLayerStore } from '@/stores'
|
||||
import { cloneData, extractLayers } from '@/utils'
|
||||
import { useLayerSelection } from '../../../composables/layer'
|
||||
import { useLayerPersistence } from '../../../composables/persistence'
|
||||
import { useCanvasInject, useCanvasRuntimeInject, useEditorUIInject } from '../../../context'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { listenEditorEmitter, setEditorEmitter } from '../../../emitter'
|
||||
import { unwrapElement } from '../../../utils'
|
||||
|
||||
export function useCanvasStage() {
|
||||
// 这里同时负责“底图渲染”和“视口控制”,因此常量集中定义在入口。
|
||||
const DEFAULT_CANVAS_WIDTH_RATIO = 0.6
|
||||
const STAGE_BACKGROUND = '#EEF1F5'
|
||||
const DEFAULT_BASE_LAYER_BACKGROUND = '#FFFFFF'
|
||||
const MIN_VIEW_SCALE = 0.1
|
||||
const MAX_VIEW_SCALE = 100
|
||||
|
||||
const { stageRef, getCanvasElement, getWrapperElement } = useCanvasInject()
|
||||
const { setBackgroundAverageColor } = useCanvasRuntimeInject()
|
||||
const layerStore = useLayerStore(pinia)
|
||||
const { clipboard } = useEditorUIInject()
|
||||
|
||||
const imageRef = shallowRef<HTMLImageElement | null>(null)
|
||||
|
||||
const { canvasId, canvasView, activeCanvasThumbnail, isHydratingCanvas, canvasInfo } = useCanvasState()
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let observedWrapper: HTMLElement | null = null
|
||||
// 平移使用屏幕坐标记录起点,再映射回当前视口偏移。
|
||||
const panState = reactive({
|
||||
active: false,
|
||||
startClientX: 0,
|
||||
startClientY: 0,
|
||||
startOffsetX: 0,
|
||||
startOffsetY: 0,
|
||||
})
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
backgroundColor: STAGE_BACKGROUND,
|
||||
}))
|
||||
|
||||
function updateCanvasSize() {
|
||||
const wrapper = getWrapperElement()
|
||||
const canvas = getCanvasElement()
|
||||
if (!wrapper || !canvas) {
|
||||
return
|
||||
}
|
||||
const rect = wrapper.getBoundingClientRect()
|
||||
const width = Math.max(1, Math.floor(rect.width))
|
||||
const height = Math.max(1, Math.floor(rect.height))
|
||||
if (canvas.width !== width) {
|
||||
canvas.width = width
|
||||
}
|
||||
if (canvas.height !== height) {
|
||||
canvas.height = height
|
||||
}
|
||||
canvasView.value.canvasWidth = width
|
||||
canvasView.value.canvasHeight = height
|
||||
updateLayout()
|
||||
}
|
||||
|
||||
function getDefaultScale() {
|
||||
// 默认缩放优先保证宽度可见,再受高度约束,避免初次进入时铺满过头。
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight || !canvasView.value.canvasWidth || !canvasView.value.canvasHeight) {
|
||||
return 1
|
||||
}
|
||||
const targetScaleByWidth = (canvasView.value.canvasWidth * DEFAULT_CANVAS_WIDTH_RATIO) / canvasView.value.imageWidth
|
||||
const maxScaleByHeight = canvasView.value.canvasHeight / canvasView.value.imageHeight
|
||||
const scale = Math.min(targetScaleByWidth, maxScaleByHeight)
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1
|
||||
}
|
||||
|
||||
function centerBaseLayer() {
|
||||
const drawWidth = canvasView.value.imageWidth * canvasView.value.scale
|
||||
const drawHeight = canvasView.value.imageHeight * canvasView.value.scale
|
||||
canvasView.value.offsetX = Math.round((canvasView.value.canvasWidth - drawWidth) / 2)
|
||||
canvasView.value.offsetY = Math.round((canvasView.value.canvasHeight - drawHeight) / 2)
|
||||
}
|
||||
|
||||
function resetViewToDefault() {
|
||||
if (!getCanvasElement()) {
|
||||
return
|
||||
}
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight || !canvasView.value.canvasWidth || !canvasView.value.canvasHeight) {
|
||||
canvasView.value.scale = 1
|
||||
canvasView.value.offsetX = 0
|
||||
canvasView.value.offsetY = 0
|
||||
drawCanvas()
|
||||
return
|
||||
}
|
||||
canvasView.value.scale = getDefaultScale()
|
||||
centerBaseLayer()
|
||||
drawCanvas()
|
||||
}
|
||||
|
||||
function updateLayout(options?: { resetView?: boolean }) {
|
||||
if (!getCanvasElement()) {
|
||||
return
|
||||
}
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight || !canvasView.value.canvasWidth || !canvasView.value.canvasHeight) {
|
||||
// 尺寸不足时也要触发一次重绘,避免保留上一张画布残影。
|
||||
drawCanvas()
|
||||
return
|
||||
}
|
||||
if (options?.resetView) {
|
||||
resetViewToDefault()
|
||||
return
|
||||
}
|
||||
drawCanvas()
|
||||
}
|
||||
|
||||
function isBaseLayerInViewport() {
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight || !canvasView.value.canvasWidth || !canvasView.value.canvasHeight || canvasView.value.scale <= 0) {
|
||||
return false
|
||||
}
|
||||
const drawWidth = canvasView.value.imageWidth * canvasView.value.scale
|
||||
const drawHeight = canvasView.value.imageHeight * canvasView.value.scale
|
||||
const left = canvasView.value.offsetX
|
||||
const top = canvasView.value.offsetY
|
||||
const right = left + drawWidth
|
||||
const bottom = top + drawHeight
|
||||
return right > 0 && bottom > 0 && left < canvasView.value.canvasWidth && top < canvasView.value.canvasHeight
|
||||
}
|
||||
|
||||
function drawCanvas() {
|
||||
const canvas = getCanvasElement()
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
return
|
||||
}
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.fillStyle = STAGE_BACKGROUND
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
const drawWidth = canvasView.value.imageWidth * canvasView.value.scale
|
||||
const drawHeight = canvasView.value.imageHeight * canvasView.value.scale
|
||||
const image = imageRef.value
|
||||
|
||||
// 每次重绘都先完整铺背景,避免缩放和平移后出现旧像素残留。
|
||||
if (drawWidth > 0 && drawHeight > 0) {
|
||||
ctx.fillStyle = canvasView.value.background || DEFAULT_BASE_LAYER_BACKGROUND
|
||||
ctx.fillRect(canvasView.value.offsetX, canvasView.value.offsetY, drawWidth, drawHeight)
|
||||
if (image) {
|
||||
ctx.drawImage(image, canvasView.value.offsetX, canvasView.value.offsetY, drawWidth, drawHeight)
|
||||
}
|
||||
else {
|
||||
// 无底图时保留一个逻辑画布区域,便于后续设置宽高。
|
||||
ctx.strokeStyle = '#D0D7DE'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(canvasView.value.offsetX, canvasView.value.offsetY, drawWidth, drawHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sampleImageAverageColor(image: HTMLImageElement) {
|
||||
// 采样缩小后的像素均值,用来给面板和文字自动选对比色。
|
||||
const width = Math.max(1, Math.min(32, image.naturalWidth || image.width || 1))
|
||||
const height = Math.max(1, Math.min(32, image.naturalHeight || image.height || 1))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) {
|
||||
return null
|
||||
}
|
||||
|
||||
ctx.drawImage(image, 0, 0, width, height)
|
||||
const imageData = ctx.getImageData(0, 0, width, height).data
|
||||
let red = 0
|
||||
let green = 0
|
||||
let blue = 0
|
||||
let alphaCount = 0
|
||||
|
||||
for (let index = 0; index < imageData.length; index += 4) {
|
||||
const alpha = imageData[index + 3] / 255
|
||||
if (alpha <= 0) {
|
||||
continue
|
||||
}
|
||||
red += imageData[index] * alpha
|
||||
green += imageData[index + 1] * alpha
|
||||
blue += imageData[index + 2] * alpha
|
||||
alphaCount += alpha
|
||||
}
|
||||
|
||||
if (!alphaCount) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toHex = (value: number) => Math.round(value / alphaCount).toString(16).padStart(2, '0')
|
||||
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`
|
||||
}
|
||||
|
||||
function scaleNumericValue(value: unknown, factor: number) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
return Math.max(0, Number((value * factor).toFixed(2)))
|
||||
}
|
||||
|
||||
function scaleLayersToBaseLayer(nextWidth: number, nextHeight: number, prevWidth: number, prevHeight: number) {
|
||||
if (!layerStore.layerList.length || prevWidth <= 0 || prevHeight <= 0 || isHydratingCanvas.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const scaleX = nextWidth / prevWidth
|
||||
const scaleY = nextHeight / prevHeight
|
||||
if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY) || (!scaleX && !scaleY)) {
|
||||
return
|
||||
}
|
||||
if (Math.abs(scaleX - 1) < 0.0001 && Math.abs(scaleY - 1) < 0.0001) {
|
||||
return
|
||||
}
|
||||
|
||||
const typographyScale = Math.sqrt(scaleX * scaleY)
|
||||
// 组件几何属性按 X/Y 缩放,文字和阴影模糊这类视觉属性按综合比例缩放。
|
||||
const nextLayers = layerStore.layerList.map((layer) => {
|
||||
const nextLayer = cloneData(layer)
|
||||
nextLayer.x = Math.round(nextLayer.x * scaleX)
|
||||
nextLayer.y = Math.round(nextLayer.y * scaleY)
|
||||
nextLayer.width = Math.max(24, Math.round(nextLayer.width * scaleX))
|
||||
nextLayer.height = Math.max(24, Math.round(nextLayer.height * scaleY))
|
||||
|
||||
const nextConfig = typeof nextLayer.config === 'object' && nextLayer.config
|
||||
? nextLayer.config as Record<string, unknown>
|
||||
: undefined
|
||||
if (nextConfig) {
|
||||
nextConfig.fontSize = scaleNumericValue(nextConfig.fontSize, typographyScale)
|
||||
nextConfig.strokeWidth = scaleNumericValue(nextConfig.strokeWidth, typographyScale)
|
||||
nextConfig.radius = scaleNumericValue(nextConfig.radius, typographyScale)
|
||||
nextConfig.shadowBlur = scaleNumericValue(nextConfig.shadowBlur, typographyScale)
|
||||
nextConfig.shadowOffsetX = scaleNumericValue(nextConfig.shadowOffsetX, scaleX)
|
||||
nextConfig.shadowOffsetY = scaleNumericValue(nextConfig.shadowOffsetY, scaleY)
|
||||
}
|
||||
|
||||
const nextStyle = typeof nextLayer.style === 'object' && nextLayer.style
|
||||
? nextLayer.style as Record<string, any>
|
||||
: undefined
|
||||
if (nextStyle?.text) {
|
||||
nextStyle.text.fontSize = scaleNumericValue(nextStyle.text.fontSize, typographyScale)
|
||||
}
|
||||
if (nextStyle?.border) {
|
||||
nextStyle.border.width = scaleNumericValue(nextStyle.border.width, typographyScale)
|
||||
nextStyle.border.radius = scaleNumericValue(nextStyle.border.radius, typographyScale)
|
||||
}
|
||||
if (nextStyle?.shadow) {
|
||||
nextStyle.shadow.blur = scaleNumericValue(nextStyle.shadow.blur, typographyScale)
|
||||
nextStyle.shadow.offsetX = scaleNumericValue(nextStyle.shadow.offsetX, scaleX)
|
||||
nextStyle.shadow.offsetY = scaleNumericValue(nextStyle.shadow.offsetY, scaleY)
|
||||
}
|
||||
|
||||
return nextLayer
|
||||
})
|
||||
|
||||
layerStore.setLayerList(nextLayers, { preserveSelection: true })
|
||||
}
|
||||
|
||||
function ensureObserver() {
|
||||
const wrapper = getWrapperElement()
|
||||
if (!wrapper) {
|
||||
return
|
||||
}
|
||||
// ResizeObserver 负责容器尺寸变化时重算画布尺寸。
|
||||
if (!resizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasSize()
|
||||
})
|
||||
}
|
||||
if (observedWrapper && observedWrapper !== wrapper) {
|
||||
resizeObserver.unobserve(observedWrapper)
|
||||
}
|
||||
if (observedWrapper !== wrapper) {
|
||||
resizeObserver.observe(wrapper)
|
||||
observedWrapper = wrapper
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackground(
|
||||
imageUrl: string,
|
||||
options?: { width?: number, height?: number, lockAspectRatio?: boolean, resetView?: boolean },
|
||||
): Promise<void> {
|
||||
// 底图加载完成后,同时同步自然尺寸、取样颜色和逻辑画布尺寸。
|
||||
const image = new Image()
|
||||
image.src = imageUrl
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve()
|
||||
image.onerror = () => reject(new Error('底图加载失败'))
|
||||
})
|
||||
|
||||
imageRef.value = image
|
||||
const averageColor = sampleImageAverageColor(image)
|
||||
canvasView.value.backgroundAverageColor = averageColor
|
||||
setBackgroundAverageColor(averageColor)
|
||||
canvasView.value.imageNaturalWidth = image.naturalWidth
|
||||
canvasView.value.imageNaturalHeight = image.naturalHeight
|
||||
const safeWidth = Number(options?.width)
|
||||
const safeHeight = Number(options?.height)
|
||||
const nextWidth = Number.isFinite(safeWidth) && safeWidth > 0
|
||||
? Math.round(safeWidth)
|
||||
: image.naturalWidth
|
||||
const nextHeight = Number.isFinite(safeHeight) && safeHeight > 0
|
||||
? Math.round(safeHeight)
|
||||
: image.naturalHeight
|
||||
|
||||
setBaseLayerSize({ width: nextWidth, height: nextHeight, options: { resetView: options?.resetView } })
|
||||
|
||||
if (typeof options?.lockAspectRatio === 'boolean') {
|
||||
canvasView.value.backgroundLockAspectRatio = options.lockAspectRatio
|
||||
}
|
||||
}
|
||||
|
||||
function clearBackground(width = 0, height = 0, options?: { resetView?: boolean }) {
|
||||
// 清底图时保留逻辑宽高的入口,便于“纯色画布”场景继续编辑。
|
||||
imageRef.value = null
|
||||
canvasView.value.backgroundAverageColor = null
|
||||
setBackgroundAverageColor(null)
|
||||
canvasView.value.imageNaturalWidth = 0
|
||||
canvasView.value.imageNaturalHeight = 0
|
||||
setBaseLayerSize({ width, height, options })
|
||||
}
|
||||
|
||||
// 批量更新背景图层尺寸,避免分别设置宽高引起多次重排抖动。
|
||||
function setBaseLayerSize({ width, height, options }: { width: number, height: number, options?: { resetView?: boolean } }) {
|
||||
const rawWidth = Math.round(width)
|
||||
const rawHeight = Math.round(height)
|
||||
const prevWidth = canvasView.value.imageWidth
|
||||
const prevHeight = canvasView.value.imageHeight
|
||||
|
||||
// 当未加载任何画布数据时允许清空尺寸,其余场景统一走画布尺寸边界。
|
||||
if (rawWidth <= 0 && rawHeight <= 0) {
|
||||
canvasView.value.imageWidth = 0
|
||||
canvasView.value.imageHeight = 0
|
||||
updateLayout({ resetView: options?.resetView })
|
||||
return
|
||||
}
|
||||
|
||||
const nextWidth = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, rawWidth))
|
||||
const nextHeight = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, rawHeight))
|
||||
scaleLayersToBaseLayer(nextWidth, nextHeight, prevWidth, prevHeight)
|
||||
canvasView.value.imageWidth = nextWidth
|
||||
canvasView.value.imageHeight = nextHeight
|
||||
updateLayout({ resetView: options?.resetView })
|
||||
}
|
||||
listenEditorEmitter('baseLayer:setSize', setBaseLayerSize)
|
||||
|
||||
async function setup() {
|
||||
watch(
|
||||
() => stageRef.value,
|
||||
(next) => {
|
||||
stageRef.value = next
|
||||
if (next) {
|
||||
// stage 引用就绪后,立即同步一次画布尺寸。
|
||||
updateCanvasSize()
|
||||
ensureObserver()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => unwrapElement(stageRef.value?.wrapperRef),
|
||||
(wrapper) => {
|
||||
if (!wrapper) {
|
||||
return
|
||||
}
|
||||
// wrapper DOM 就绪后,再测量一次尺寸。
|
||||
updateCanvasSize()
|
||||
ensureObserver()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
updateCanvasSize()
|
||||
ensureObserver()
|
||||
requestAnimationFrame(() => {
|
||||
updateCanvasSize()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => unwrapElement(stageRef.value?.wrapperRef),
|
||||
(wrapper) => {
|
||||
if (!wrapper) {
|
||||
return
|
||||
}
|
||||
updateCanvasSize()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointermove', onPanPointerMove)
|
||||
window.removeEventListener('pointerup', onPanPointerUp)
|
||||
if (resizeObserver && observedWrapper) {
|
||||
resizeObserver.unobserve(observedWrapper)
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
observedWrapper = null
|
||||
})
|
||||
|
||||
function setScale(newScale: number, anchor?: { clientX: number, clientY: number }) {
|
||||
const clampedScale = Math.min(Math.max(newScale, MIN_VIEW_SCALE), MAX_VIEW_SCALE)
|
||||
const prevScale = canvasView.value.scale
|
||||
if (!Number.isFinite(clampedScale) || prevScale <= 0 || clampedScale === prevScale) {
|
||||
return
|
||||
}
|
||||
|
||||
if (anchor) {
|
||||
// 以鼠标所在点为缩放锚点,保证缩放前后的视觉焦点不漂移。
|
||||
const canvas = getCanvasElement()
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const canvasX = anchor.clientX - rect.left
|
||||
const canvasY = anchor.clientY - rect.top
|
||||
const imageX = (canvasX - canvasView.value.offsetX) / prevScale
|
||||
const imageY = (canvasY - canvasView.value.offsetY) / prevScale
|
||||
canvasView.value.scale = clampedScale
|
||||
canvasView.value.offsetX = canvasX - imageX * clampedScale
|
||||
canvasView.value.offsetY = canvasY - imageY * clampedScale
|
||||
drawCanvas()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
canvasView.value.scale = clampedScale
|
||||
drawCanvas()
|
||||
}
|
||||
|
||||
function onPanPointerMove(event: PointerEvent) {
|
||||
if (!panState.active) {
|
||||
return
|
||||
}
|
||||
canvasView.value.offsetX = panState.startOffsetX + (event.clientX - panState.startClientX)
|
||||
canvasView.value.offsetY = panState.startOffsetY + (event.clientY - panState.startClientY)
|
||||
drawCanvas()
|
||||
}
|
||||
|
||||
function onPanPointerUp() {
|
||||
panState.active = false
|
||||
window.removeEventListener('pointermove', onPanPointerMove)
|
||||
window.removeEventListener('pointerup', onPanPointerUp)
|
||||
setEditorEmitter('propertyPanel:resume')
|
||||
}
|
||||
|
||||
function beginPan(event: PointerEvent) {
|
||||
if (!getCanvasElement()) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
// 拖动画布期间暂时隐藏属性面板,避免遮挡和跟随重算。
|
||||
setEditorEmitter('propertyPanel:suspend')
|
||||
panState.active = true
|
||||
panState.startClientX = event.clientX
|
||||
panState.startClientY = event.clientY
|
||||
panState.startOffsetX = canvasView.value.offsetX
|
||||
panState.startOffsetY = canvasView.value.offsetY
|
||||
window.addEventListener('pointermove', onPanPointerMove)
|
||||
window.addEventListener('pointerup', onPanPointerUp)
|
||||
return true
|
||||
}
|
||||
|
||||
function panBy(deltaX: number, deltaY: number) {
|
||||
if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) {
|
||||
return
|
||||
}
|
||||
if (!deltaX && !deltaY) {
|
||||
return
|
||||
}
|
||||
canvasView.value.offsetX += deltaX
|
||||
canvasView.value.offsetY += deltaY
|
||||
drawCanvas()
|
||||
}
|
||||
|
||||
function centerTarget(bounds: { x: number, y: number, width: number, height: number }) {
|
||||
if (!Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) || !Number.isFinite(bounds.width) || !Number.isFinite(bounds.height)) {
|
||||
return
|
||||
}
|
||||
if (!canvasView.value.canvasWidth || !canvasView.value.canvasHeight || canvasView.value.scale <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetCenterX = bounds.x + bounds.width / 2
|
||||
const targetCenterY = bounds.y + bounds.height / 2
|
||||
canvasView.value.offsetX = Math.round(canvasView.value.canvasWidth / 2 - targetCenterX * canvasView.value.scale)
|
||||
canvasView.value.offsetY = Math.round(canvasView.value.canvasHeight / 2 - targetCenterY * canvasView.value.scale)
|
||||
drawCanvas()
|
||||
}
|
||||
|
||||
listenEditorEmitter('viewport:setScale', ({ newScale, anchor }) => {
|
||||
setScale(newScale, anchor)
|
||||
})
|
||||
|
||||
listenEditorEmitter('viewport:panBy', ({ deltaX, deltaY }) => {
|
||||
panBy(deltaX, deltaY)
|
||||
})
|
||||
|
||||
listenEditorEmitter('viewport:beginPan', (event) => {
|
||||
beginPan(event)
|
||||
})
|
||||
|
||||
listenEditorEmitter('viewport:centerTarget', (bounds) => {
|
||||
centerTarget(bounds)
|
||||
})
|
||||
|
||||
function zoomIn() {
|
||||
setScale(canvasView.value.scale * 1.1)
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
setScale(canvasView.value.scale / 1.1)
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
resetViewToDefault()
|
||||
}
|
||||
|
||||
function resetCanvasPresentation(options?: { resetView?: boolean }) {
|
||||
useLayerSelection().replaceLayers([])
|
||||
layerStore.deactivateBaseLayerSelection()
|
||||
clipboard.resetClipboard()
|
||||
activeCanvasThumbnail.value = ''
|
||||
canvasInfo.value = null
|
||||
canvasView.value.backgroundLockAspectRatio = true
|
||||
canvasView.value.background = DEFAULT_BASE_LAYER_BACKGROUND
|
||||
canvasView.value.backgroundAverageColor = null
|
||||
setBackgroundAverageColor(null)
|
||||
clearBackground(0, 0, { resetView: options?.resetView })
|
||||
}
|
||||
|
||||
// 按画布 ID 从 request 层加载组件和底图
|
||||
async function hydrateCanvasData(canvasId: string, hydrateOptions?: { forceResetView?: boolean }) {
|
||||
// 恢复画布时以服务端详情为真值,统一重建底图、尺寸和组件列表。
|
||||
const detail = canvasInfo.value?.id === canvasId
|
||||
? canvasInfo.value
|
||||
: await getCanvasByIdApi(canvasId)
|
||||
canvasInfo.value = detail
|
||||
|
||||
if (!detail) {
|
||||
resetCanvasPresentation({ resetView: hydrateOptions?.forceResetView ?? true })
|
||||
return
|
||||
}
|
||||
|
||||
const nextLayers = extractLayers(detail.components as unknown[])
|
||||
const backgroundConfig = detail.config?.style?.background
|
||||
const lockAspectRatio = typeof backgroundConfig?.lockAspectRatio === 'boolean'
|
||||
? backgroundConfig.lockAspectRatio
|
||||
: true
|
||||
const backgroundColor = typeof backgroundConfig?.color === 'string' && backgroundConfig.color.trim()
|
||||
? backgroundConfig.color.trim()
|
||||
: '#FFFFFF'
|
||||
const hasConfiguredBackgroundSize = typeof backgroundConfig?.width === 'number'
|
||||
&& backgroundConfig.width > 0
|
||||
&& typeof backgroundConfig?.height === 'number'
|
||||
&& backgroundConfig.height > 0
|
||||
const nextImageWidth = Math.max(MIN_CANVAS_WIDTH, hasConfiguredBackgroundSize ? backgroundConfig.width! : detail.width!)
|
||||
const nextImageHeight = Math.max(MIN_CANVAS_HEIGHT, hasConfiguredBackgroundSize ? backgroundConfig.height! : detail.height!)
|
||||
|
||||
isHydratingCanvas.value = true
|
||||
try {
|
||||
// 切换画布时先完成底图更新,再一次性替换组件,减少中间态闪烁。
|
||||
const nextThumbnail = detail.thumbnail || ''
|
||||
activeCanvasThumbnail.value = nextThumbnail
|
||||
canvasView.value.backgroundLockAspectRatio = lockAspectRatio
|
||||
canvasView.value.background = backgroundColor
|
||||
setBackgroundAverageColor(null)
|
||||
if (detail.thumbnail) {
|
||||
await loadBackground(detail.thumbnail, {
|
||||
width: hasConfiguredBackgroundSize ? nextImageWidth : undefined,
|
||||
height: hasConfiguredBackgroundSize ? nextImageHeight : undefined,
|
||||
lockAspectRatio,
|
||||
})
|
||||
if (canvasView.value.imageWidth < MIN_CANVAS_WIDTH || canvasView.value.imageHeight < MIN_CANVAS_HEIGHT) {
|
||||
setEditorEmitter('baseLayer:setSize', {
|
||||
width: Math.max(MIN_CANVAS_WIDTH, canvasView.value.imageWidth),
|
||||
height: Math.max(MIN_CANVAS_HEIGHT, canvasView.value.imageHeight),
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
clearBackground(nextImageWidth, nextImageHeight)
|
||||
}
|
||||
useLayerSelection().replaceLayers(nextLayers)
|
||||
clipboard.resetClipboard()
|
||||
if (hydrateOptions?.forceResetView || !isBaseLayerInViewport()) {
|
||||
resetViewToDefault()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
isHydratingCanvas.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setup()
|
||||
|
||||
if (!canvasId.value) {
|
||||
resetCanvasPresentation({ resetView: true })
|
||||
return
|
||||
}
|
||||
// 首次进入编辑器时恢复画布
|
||||
hydrateCanvasData(canvasId.value, { forceResetView: true })
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
watch(
|
||||
() => canvasId.value,
|
||||
async (nextCanvasId, prevCanvasId) => {
|
||||
if (!nextCanvasId) {
|
||||
resetCanvasPresentation({ resetView: true })
|
||||
return
|
||||
}
|
||||
if (nextCanvasId === prevCanvasId) {
|
||||
return
|
||||
}
|
||||
// 切换画布时重新恢复数据
|
||||
await hydrateCanvasData(nextCanvasId)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => canvasView.value.scale,
|
||||
(scale) => {
|
||||
const zoom = Math.max(1, Math.round(scale * 100))
|
||||
canvasStore.setCanvasZoom(zoom)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const { persistCanvasBaseLayer } = useLayerPersistence()
|
||||
watch(
|
||||
() => [canvasView.value.imageWidth, canvasView.value.imageHeight, canvasView.value.backgroundLockAspectRatio, canvasView.value.background],
|
||||
() => {
|
||||
// 底图尺寸、背景色或宽高比锁定状态变化后统一自动保存。
|
||||
persistCanvasBaseLayer()
|
||||
},
|
||||
)
|
||||
|
||||
async function importBackground(source: string) {
|
||||
activeCanvasThumbnail.value = source
|
||||
try {
|
||||
await loadBackground(source, {
|
||||
lockAspectRatio: canvasView.value.backgroundLockAspectRatio,
|
||||
})
|
||||
if (canvasView.value.imageWidth < MIN_CANVAS_WIDTH || canvasView.value.imageHeight < MIN_CANVAS_HEIGHT) {
|
||||
setBaseLayerSize(
|
||||
{ width: Math.max(MIN_CANVAS_WIDTH, canvasView.value.imageWidth), height: Math.max(MIN_CANVAS_HEIGHT, canvasView.value.imageHeight) },
|
||||
)
|
||||
}
|
||||
persistCanvasBaseLayer()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[canvas] failed to import background', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onReplaceBackground(payload: { source: string }) {
|
||||
if (!payload.source) {
|
||||
return
|
||||
}
|
||||
await importBackground(payload.source)
|
||||
layerStore.activateBaseLayerSelection()
|
||||
}
|
||||
listenEditorEmitter('propertyPanel:replaceBackground', onReplaceBackground)
|
||||
|
||||
function onRemoveBackground() {
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
activeCanvasThumbnail.value = ''
|
||||
clearBackground(canvasView.value.imageWidth, canvasView.value.imageHeight)
|
||||
persistCanvasBaseLayer()
|
||||
layerStore.activateBaseLayerSelection()
|
||||
}
|
||||
listenEditorEmitter('propertyPanel:removeBackground', onRemoveBackground)
|
||||
|
||||
return {
|
||||
setup,
|
||||
canvasView,
|
||||
wrapperStyle,
|
||||
updateCanvasSize,
|
||||
updateLayout,
|
||||
getWrapperElement,
|
||||
getCanvasElement,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
resetViewToDefault,
|
||||
isBaseLayerInViewport,
|
||||
setScale,
|
||||
beginPan,
|
||||
panBy,
|
||||
loadBackground,
|
||||
clearBackground,
|
||||
setBaseLayerSize,
|
||||
hydrateCanvasData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import type { Layer, ResizeHandle } from '../../../types'
|
||||
import type { Rect } from './useSnapGuides'
|
||||
import { onBeforeUnmount, reactive } from 'vue'
|
||||
import { useLayerStore } from '@/stores'
|
||||
import { useCanvasInject } from '../../../context'
|
||||
import { useCanvasState } from '../../../context/state'
|
||||
import { listenEditorEmitter, setEditorEmitter } from '../../../emitter'
|
||||
import {
|
||||
buildGroupPositionUpdates,
|
||||
buildGroupRectUpdates,
|
||||
getLayerGroupById,
|
||||
} from '../../../grouping'
|
||||
import { clamp } from '../../../utils'
|
||||
import { useSnapGuides } from './useSnapGuides'
|
||||
|
||||
interface LayerRectSnapshot {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function useLayerInteraction() {
|
||||
const { canvasView, layerList, selectedLayerId, selectedLayerIds } = useCanvasState()
|
||||
const { getCanvasElement } = useCanvasInject()
|
||||
const layerStore = useLayerStore()
|
||||
const { snapGuides, updateGuides, clearGuides, calcSnap, calcResizeSnap } = useSnapGuides()
|
||||
|
||||
const dragState = reactive({
|
||||
active: false,
|
||||
ids: [] as string[],
|
||||
groupId: null as string | null,
|
||||
startPointerX: 0,
|
||||
startPointerY: 0,
|
||||
startRects: new Map<string, LayerRectSnapshot>(),
|
||||
startGroupBounds: null as Omit<LayerRectSnapshot, 'id'> | null,
|
||||
})
|
||||
|
||||
const resizeState = reactive({
|
||||
id: null as string | null,
|
||||
groupId: null as string | null,
|
||||
handle: null as ResizeHandle | null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startLeft: 0,
|
||||
startTop: 0,
|
||||
startRight: 0,
|
||||
startBottom: 0,
|
||||
startRects: new Map<string, LayerRectSnapshot>(),
|
||||
})
|
||||
|
||||
function getPointerImagePosition(event: PointerEvent) {
|
||||
// 舞台交互全部在“底图坐标系”里计算,避免受缩放和平移影响。
|
||||
const rect = getCanvasElement()?.getBoundingClientRect()
|
||||
if (!rect || canvasView.value.scale <= 0) {
|
||||
return null
|
||||
}
|
||||
const canvasX = event.clientX - rect.left
|
||||
const canvasY = event.clientY - rect.top
|
||||
const imageX = (canvasX - canvasView.value.offsetX) / canvasView.value.scale
|
||||
const imageY = (canvasY - canvasView.value.offsetY) / canvasView.value.scale
|
||||
return { imageX, imageY }
|
||||
}
|
||||
|
||||
function isLocked(layer: Layer) {
|
||||
return Boolean(layer.config?.locked)
|
||||
}
|
||||
|
||||
function createSnapshotMap(ids: string[]) {
|
||||
// 拖拽/缩放期间基于起始快照反复计算,避免连续累加造成误差。
|
||||
return new Map(
|
||||
layerList.value
|
||||
.filter(entry => ids.includes(entry.id))
|
||||
.map(entry => [
|
||||
entry.id,
|
||||
{
|
||||
id: entry.id,
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function buildOtherRects(excludeIds: string[]): Rect[] {
|
||||
const excludeSet = new Set(excludeIds)
|
||||
return layerList.value
|
||||
.filter(l => !excludeSet.has(l.id) && l.config?.visible !== false)
|
||||
.map(l => ({ x: l.x, y: l.y, width: l.width, height: l.height }))
|
||||
}
|
||||
|
||||
function getSnapshotLayers(ids: string[], map: Map<string, LayerRectSnapshot>) {
|
||||
return ids
|
||||
.map((id) => {
|
||||
const snapshot = map.get(id)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: snapshot.id,
|
||||
type: 'custom',
|
||||
x: snapshot.x,
|
||||
y: snapshot.y,
|
||||
width: snapshot.width,
|
||||
height: snapshot.height,
|
||||
} as Layer
|
||||
})
|
||||
.filter(Boolean) as Layer[]
|
||||
}
|
||||
|
||||
function startDrag(ids: string[], pointer: { imageX: number, imageY: number }, groupId?: string | null) {
|
||||
setEditorEmitter('propertyPanel:suspend')
|
||||
dragState.active = true
|
||||
dragState.ids = ids
|
||||
dragState.groupId = groupId ?? null
|
||||
dragState.startPointerX = pointer.imageX
|
||||
dragState.startPointerY = pointer.imageY
|
||||
dragState.startRects = createSnapshotMap(ids)
|
||||
dragState.startGroupBounds = dragState.groupId
|
||||
? (() => {
|
||||
const group = getLayerGroupById(layerList.value, dragState.groupId || '')
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
x: group.x,
|
||||
y: group.y,
|
||||
width: group.width,
|
||||
height: group.height,
|
||||
}
|
||||
})()
|
||||
: null
|
||||
|
||||
window.addEventListener('pointermove', onLayerPointerMove)
|
||||
window.addEventListener('pointerup', onLayerPointerUp)
|
||||
}
|
||||
|
||||
function onLayerPointerDown(payload: { event: PointerEvent, id: string }) {
|
||||
const { event, id } = payload
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
if (!getCanvasElement() || canvasView.value.scale <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = getPointerImagePosition(event)
|
||||
const layer = layerList.value.find(entry => entry.id === id)
|
||||
if (!layer || !pointer || isLocked(layer)) {
|
||||
return
|
||||
}
|
||||
|
||||
const additive = event.metaKey || event.ctrlKey || event.shiftKey
|
||||
|
||||
// 拖动多选中的任一图层时,沿用当前可拖动的选区一起移动。
|
||||
const activeIds = (selectedLayerIds.value.includes(id) && selectedLayerIds.value.length > 1
|
||||
? [...selectedLayerIds.value]
|
||||
: [id]
|
||||
).filter((entryId) => {
|
||||
const entry = layerList.value.find(layer => layer.id === entryId)
|
||||
return entry ? !isLocked(entry) : false
|
||||
})
|
||||
|
||||
if (!activeIds.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!additive && (!selectedLayerIds.value.includes(id) || selectedLayerIds.value.length <= 1)) {
|
||||
layerStore.setSelectedLayerIds([id], id, { groupId: null })
|
||||
}
|
||||
if (!additive) {
|
||||
selectedLayerId.value = id
|
||||
}
|
||||
startDrag(activeIds, pointer)
|
||||
event.preventDefault()
|
||||
}
|
||||
listenEditorEmitter('canvasStage:layerPointerDown', onLayerPointerDown)
|
||||
|
||||
function onGroupPointerDown(payload: { event: PointerEvent, groupId: string }) {
|
||||
const { event, groupId } = payload
|
||||
if (event.button !== 0 || !getCanvasElement() || canvasView.value.scale <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = getPointerImagePosition(event)
|
||||
const group = getLayerGroupById(layerList.value, groupId)
|
||||
if (!group || !pointer || group.layers.some(isLocked)) {
|
||||
return
|
||||
}
|
||||
|
||||
layerStore.setSelectedLayerIds(group.layerIds, group.layerIds[0] ?? '', { groupId })
|
||||
startDrag(group.layerIds, pointer, groupId)
|
||||
event.preventDefault()
|
||||
}
|
||||
listenEditorEmitter('canvasStage:groupPointerDown', onGroupPointerDown)
|
||||
|
||||
function onResizePointerDown(payload: { event: PointerEvent, id: string, handle: ResizeHandle }) {
|
||||
const { event, id, handle } = payload
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = getPointerImagePosition(event)
|
||||
const layer = layerList.value.find(entry => entry.id === id)
|
||||
if (!layer || !pointer || isLocked(layer)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setEditorEmitter('propertyPanel:suspend')
|
||||
layerStore.setSelectedLayerIds([id], id, { groupId: null })
|
||||
selectedLayerId.value = id
|
||||
|
||||
resizeState.id = id
|
||||
resizeState.groupId = null
|
||||
resizeState.handle = handle
|
||||
resizeState.startX = pointer.imageX
|
||||
resizeState.startY = pointer.imageY
|
||||
resizeState.startLeft = layer.x
|
||||
resizeState.startTop = layer.y
|
||||
resizeState.startRight = layer.x + layer.width
|
||||
resizeState.startBottom = layer.y + layer.height
|
||||
resizeState.startRects.clear()
|
||||
|
||||
window.addEventListener('pointermove', onResizePointerMove)
|
||||
window.addEventListener('pointerup', onResizePointerUp)
|
||||
}
|
||||
listenEditorEmitter('canvasStage:layerResizePointerDown', onResizePointerDown)
|
||||
|
||||
function onGroupResizePointerDown(payload: { event: PointerEvent, groupId: string, handle: ResizeHandle }) {
|
||||
const { event, groupId, handle } = payload
|
||||
if (event.button !== 0 || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = getPointerImagePosition(event)
|
||||
const group = getLayerGroupById(layerList.value, groupId)
|
||||
if (!group || !pointer || group.layers.some(isLocked)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setEditorEmitter('propertyPanel:suspend')
|
||||
layerStore.setSelectedLayerIds(group.layerIds, group.layerIds[0] ?? '', { groupId })
|
||||
|
||||
resizeState.id = null
|
||||
resizeState.groupId = groupId
|
||||
resizeState.handle = handle
|
||||
resizeState.startX = pointer.imageX
|
||||
resizeState.startY = pointer.imageY
|
||||
resizeState.startLeft = group.x
|
||||
resizeState.startTop = group.y
|
||||
resizeState.startRight = group.x + group.width
|
||||
resizeState.startBottom = group.y + group.height
|
||||
resizeState.startRects = createSnapshotMap(group.layerIds)
|
||||
|
||||
window.addEventListener('pointermove', onResizePointerMove)
|
||||
window.addEventListener('pointerup', onResizePointerUp)
|
||||
}
|
||||
listenEditorEmitter('canvasStage:groupResizePointerDown', onGroupResizePointerDown)
|
||||
|
||||
function onLayerPointerMove(event: PointerEvent) {
|
||||
if (!dragState.active || !dragState.ids.length || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
const pointer = getPointerImagePosition(event)
|
||||
if (!pointer) {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = pointer.imageX - dragState.startPointerX
|
||||
const deltaY = pointer.imageY - dragState.startPointerY
|
||||
const canvasSize = { width: canvasView.value.imageWidth, height: canvasView.value.imageHeight }
|
||||
const skipSnap = event.altKey
|
||||
|
||||
if (dragState.groupId && dragState.startGroupBounds) {
|
||||
const bounds = dragState.startGroupBounds
|
||||
const rawX = bounds.x + deltaX
|
||||
const rawY = bounds.y + deltaY
|
||||
let nextX: number
|
||||
let nextY: number
|
||||
|
||||
if (skipSnap) {
|
||||
nextX = rawX
|
||||
nextY = rawY
|
||||
clearGuides()
|
||||
}
|
||||
else {
|
||||
const targetRect: Rect = { x: rawX, y: rawY, width: bounds.width, height: bounds.height }
|
||||
const snap = calcSnap(targetRect, buildOtherRects(dragState.ids), canvasSize)
|
||||
nextX = snap.snappedX
|
||||
nextY = snap.snappedY
|
||||
updateGuides({ guides: snap.guides, distances: snap.distances })
|
||||
}
|
||||
|
||||
nextX = clamp(nextX, 0, Math.max(0, canvasSize.width - bounds.width))
|
||||
nextY = clamp(nextY, 0, Math.max(0, canvasSize.height - bounds.height))
|
||||
|
||||
const updates = buildGroupPositionUpdates(
|
||||
getSnapshotLayers(dragState.ids, dragState.startRects),
|
||||
nextX - bounds.x,
|
||||
nextY - bounds.y,
|
||||
)
|
||||
setEditorEmitter('layer:updatePositions', updates)
|
||||
return
|
||||
}
|
||||
|
||||
if (dragState.ids.length === 1) {
|
||||
const id = dragState.ids[0]
|
||||
const start = dragState.startRects.get(id)
|
||||
if (!start) {
|
||||
return
|
||||
}
|
||||
const rawX = start.x + deltaX
|
||||
const rawY = start.y + deltaY
|
||||
let nextX: number
|
||||
let nextY: number
|
||||
|
||||
if (skipSnap) {
|
||||
nextX = rawX
|
||||
nextY = rawY
|
||||
clearGuides()
|
||||
}
|
||||
else {
|
||||
const targetRect: Rect = { x: rawX, y: rawY, width: start.width, height: start.height }
|
||||
const snap = calcSnap(targetRect, buildOtherRects([id]), canvasSize)
|
||||
nextX = snap.snappedX
|
||||
nextY = snap.snappedY
|
||||
updateGuides({ guides: snap.guides, distances: snap.distances })
|
||||
}
|
||||
|
||||
nextX = clamp(nextX, 0, Math.max(0, canvasSize.width - start.width))
|
||||
nextY = clamp(nextY, 0, Math.max(0, canvasSize.height - start.height))
|
||||
setEditorEmitter('layer:updatePosition', { id, nextX, nextY })
|
||||
return
|
||||
}
|
||||
|
||||
// 多选拖拽:用包围盒做吸附,delta 统一应用
|
||||
const allStarts = dragState.ids.map(id => dragState.startRects.get(id)).filter(Boolean) as LayerRectSnapshot[]
|
||||
const bboxX = Math.min(...allStarts.map(s => s.x + deltaX))
|
||||
const bboxY = Math.min(...allStarts.map(s => s.y + deltaY))
|
||||
const bboxRight = Math.max(...allStarts.map(s => s.x + s.width + deltaX))
|
||||
const bboxBottom = Math.max(...allStarts.map(s => s.y + s.height + deltaY))
|
||||
const bboxRect: Rect = { x: bboxX, y: bboxY, width: bboxRight - bboxX, height: bboxBottom - bboxY }
|
||||
|
||||
let snapDeltaX = 0
|
||||
let snapDeltaY = 0
|
||||
|
||||
if (skipSnap) {
|
||||
clearGuides()
|
||||
}
|
||||
else {
|
||||
const snap = calcSnap(bboxRect, buildOtherRects(dragState.ids), canvasSize)
|
||||
snapDeltaX = snap.snappedX - bboxX
|
||||
snapDeltaY = snap.snappedY - bboxY
|
||||
updateGuides({ guides: snap.guides, distances: snap.distances })
|
||||
}
|
||||
|
||||
const updates = dragState.ids
|
||||
.map((id) => {
|
||||
const start = dragState.startRects.get(id)
|
||||
if (!start) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id,
|
||||
nextX: clamp(start.x + deltaX + snapDeltaX, 0, Math.max(0, canvasSize.width - start.width)),
|
||||
nextY: clamp(start.y + deltaY + snapDeltaY, 0, Math.max(0, canvasSize.height - start.height)),
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: string, nextX: number, nextY: number }>
|
||||
|
||||
setEditorEmitter('layer:updatePositions', updates)
|
||||
}
|
||||
|
||||
function onLayerPointerUp() {
|
||||
dragState.active = false
|
||||
dragState.ids = []
|
||||
dragState.groupId = null
|
||||
dragState.startRects.clear()
|
||||
dragState.startGroupBounds = null
|
||||
clearGuides()
|
||||
window.removeEventListener('pointermove', onLayerPointerMove)
|
||||
window.removeEventListener('pointerup', onLayerPointerUp)
|
||||
setEditorEmitter('propertyPanel:resume')
|
||||
}
|
||||
|
||||
function onResizePointerMove(event: PointerEvent) {
|
||||
if (!resizeState.handle || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
const pointer = getPointerImagePosition(event)
|
||||
if (!pointer) {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = pointer.imageX - resizeState.startX
|
||||
const deltaY = pointer.imageY - resizeState.startY
|
||||
const minSize = 24
|
||||
const canvasSize = { width: canvasView.value.imageWidth, height: canvasView.value.imageHeight }
|
||||
const skipSnap = event.altKey
|
||||
|
||||
let left = resizeState.startLeft
|
||||
let right = resizeState.startRight
|
||||
let top = resizeState.startTop
|
||||
let bottom = resizeState.startBottom
|
||||
|
||||
// 先计算原始边缘位置
|
||||
if (resizeState.handle.includes('w')) {
|
||||
left = resizeState.startLeft + deltaX
|
||||
}
|
||||
if (resizeState.handle.includes('e')) {
|
||||
right = resizeState.startRight + deltaX
|
||||
}
|
||||
if (resizeState.handle.includes('n')) {
|
||||
top = resizeState.startTop + deltaY
|
||||
}
|
||||
if (resizeState.handle.includes('s')) {
|
||||
bottom = resizeState.startBottom + deltaY
|
||||
}
|
||||
|
||||
// 吸附
|
||||
if (!skipSnap) {
|
||||
const edges: ('left' | 'right' | 'top' | 'bottom')[] = []
|
||||
if (resizeState.handle.includes('w'))
|
||||
edges.push('left')
|
||||
if (resizeState.handle.includes('e'))
|
||||
edges.push('right')
|
||||
if (resizeState.handle.includes('n'))
|
||||
edges.push('top')
|
||||
if (resizeState.handle.includes('s'))
|
||||
edges.push('bottom')
|
||||
|
||||
const currentRect: Rect = { x: left, y: top, width: right - left, height: bottom - top }
|
||||
const excludeId = resizeState.id ? [resizeState.id] : [...resizeState.startRects.keys()]
|
||||
const snap = calcResizeSnap(edges, currentRect, buildOtherRects(excludeId), canvasSize)
|
||||
|
||||
if (snap.snappedEdges.left !== undefined)
|
||||
left = snap.snappedEdges.left
|
||||
if (snap.snappedEdges.right !== undefined)
|
||||
right = snap.snappedEdges.right
|
||||
if (snap.snappedEdges.top !== undefined)
|
||||
top = snap.snappedEdges.top
|
||||
if (snap.snappedEdges.bottom !== undefined)
|
||||
bottom = snap.snappedEdges.bottom
|
||||
|
||||
updateGuides({ guides: snap.guides, distances: snap.distances })
|
||||
}
|
||||
else {
|
||||
clearGuides()
|
||||
}
|
||||
|
||||
// clamp 边界约束
|
||||
if (resizeState.handle.includes('w'))
|
||||
left = clamp(left, 0, resizeState.startRight - minSize)
|
||||
if (resizeState.handle.includes('e'))
|
||||
right = clamp(right, resizeState.startLeft + minSize, canvasSize.width)
|
||||
if (resizeState.handle.includes('n'))
|
||||
top = clamp(top, 0, resizeState.startBottom - minSize)
|
||||
if (resizeState.handle.includes('s'))
|
||||
bottom = clamp(bottom, resizeState.startTop + minSize, canvasSize.height)
|
||||
|
||||
const nextWidth = clamp(right - left, minSize, canvasSize.width)
|
||||
const nextHeight = clamp(bottom - top, minSize, canvasSize.height)
|
||||
const nextX = clamp(left, 0, Math.max(0, canvasSize.width - nextWidth))
|
||||
const nextY = clamp(top, 0, Math.max(0, canvasSize.height - nextHeight))
|
||||
|
||||
if (resizeState.groupId) {
|
||||
const updates = buildGroupRectUpdates(
|
||||
getSnapshotLayers([...resizeState.startRects.keys()], resizeState.startRects),
|
||||
{
|
||||
x: resizeState.startLeft,
|
||||
y: resizeState.startTop,
|
||||
width: resizeState.startRight - resizeState.startLeft,
|
||||
height: resizeState.startBottom - resizeState.startTop,
|
||||
},
|
||||
{ x: nextX, y: nextY, width: nextWidth, height: nextHeight },
|
||||
)
|
||||
setEditorEmitter('layer:updateRects', updates)
|
||||
return
|
||||
}
|
||||
|
||||
if (!resizeState.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const layer = layerList.value.find(entry => entry.id === resizeState.id)
|
||||
if (!layer || isLocked(layer)) {
|
||||
return
|
||||
}
|
||||
|
||||
setEditorEmitter('layer:updateRect', { id: layer.id, nextX, nextY, nextWidth, nextHeight })
|
||||
}
|
||||
|
||||
function onResizePointerUp() {
|
||||
resizeState.id = null
|
||||
resizeState.groupId = null
|
||||
resizeState.handle = null
|
||||
resizeState.startRects.clear()
|
||||
clearGuides()
|
||||
window.removeEventListener('pointermove', onResizePointerMove)
|
||||
window.removeEventListener('pointerup', onResizePointerUp)
|
||||
setEditorEmitter('propertyPanel:resume')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointermove', onLayerPointerMove)
|
||||
window.removeEventListener('pointerup', onLayerPointerUp)
|
||||
window.removeEventListener('pointermove', onResizePointerMove)
|
||||
window.removeEventListener('pointerup', onResizePointerUp)
|
||||
})
|
||||
|
||||
return {
|
||||
snapGuides,
|
||||
onLayerPointerDown,
|
||||
onResizePointerDown,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// ---- 类型定义 ----
|
||||
|
||||
export interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface GuideLine {
|
||||
type: 'edge' | 'center' | 'canvas'
|
||||
direction: 'horizontal' | 'vertical'
|
||||
position: number
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface DistanceLabel {
|
||||
direction: 'horizontal' | 'vertical'
|
||||
x: number
|
||||
y: number
|
||||
distance: number
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
snappedX: number
|
||||
snappedY: number
|
||||
guides: GuideLine[]
|
||||
distances: DistanceLabel[]
|
||||
}
|
||||
|
||||
export interface ResizeSnapResult {
|
||||
snappedEdges: Partial<Record<'left' | 'right' | 'top' | 'bottom', number>>
|
||||
guides: GuideLine[]
|
||||
distances: DistanceLabel[]
|
||||
}
|
||||
|
||||
export interface SnapGuidesState {
|
||||
guides: GuideLine[]
|
||||
distances: DistanceLabel[]
|
||||
}
|
||||
|
||||
const SNAP_THRESHOLD = 8
|
||||
|
||||
// ---- 内部工具 ----
|
||||
|
||||
interface SnapCandidate {
|
||||
targetLine: number
|
||||
refLine: number
|
||||
distance: number
|
||||
type: 'edge' | 'center' | 'canvas'
|
||||
targetEdge: string
|
||||
refRect: Rect
|
||||
}
|
||||
|
||||
function extractVerticalLines(rect: Rect): { left: number, right: number, centerX: number } {
|
||||
return {
|
||||
left: rect.x,
|
||||
right: rect.x + rect.width,
|
||||
centerX: rect.x + rect.width / 2,
|
||||
}
|
||||
}
|
||||
|
||||
function extractHorizontalLines(rect: Rect): { top: number, bottom: number, centerY: number } {
|
||||
return {
|
||||
top: rect.y,
|
||||
bottom: rect.y + rect.height,
|
||||
centerY: rect.y + rect.height / 2,
|
||||
}
|
||||
}
|
||||
|
||||
function findBestSnap(
|
||||
targetRect: Rect,
|
||||
otherRects: Rect[],
|
||||
canvasRect: Rect,
|
||||
threshold: number,
|
||||
) {
|
||||
const allRefs: Array<{ rect: Rect, isCanvas: boolean }> = [
|
||||
...otherRects.map(r => ({ rect: r, isCanvas: false })),
|
||||
{ rect: canvasRect, isCanvas: true },
|
||||
]
|
||||
|
||||
const vCandidates: SnapCandidate[] = []
|
||||
const hCandidates: SnapCandidate[] = []
|
||||
|
||||
const targetV = extractVerticalLines(targetRect)
|
||||
const targetH = extractHorizontalLines(targetRect)
|
||||
|
||||
for (const { rect: refRect, isCanvas } of allRefs) {
|
||||
const refV = extractVerticalLines(refRect)
|
||||
const refH = extractHorizontalLines(refRect)
|
||||
const type = isCanvas ? 'canvas' as const : 'edge' as const
|
||||
|
||||
for (const [tKey, tVal] of Object.entries(targetV)) {
|
||||
for (const [rKey, rVal] of Object.entries(refV)) {
|
||||
const dist = Math.abs(tVal - rVal)
|
||||
if (dist <= threshold) {
|
||||
const rType = (rKey === 'centerX' || tKey === 'centerX') && !isCanvas ? 'center' as const : type
|
||||
vCandidates.push({
|
||||
targetLine: tVal,
|
||||
refLine: rVal,
|
||||
distance: dist,
|
||||
type: rType,
|
||||
targetEdge: tKey,
|
||||
refRect,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tKey, tVal] of Object.entries(targetH)) {
|
||||
for (const [rKey, rVal] of Object.entries(refH)) {
|
||||
const dist = Math.abs(tVal - rVal)
|
||||
if (dist <= threshold) {
|
||||
const rType = (rKey === 'centerY' || tKey === 'centerY') && !isCanvas ? 'center' as const : type
|
||||
hCandidates.push({
|
||||
targetLine: tVal,
|
||||
refLine: rVal,
|
||||
distance: dist,
|
||||
type: rType,
|
||||
targetEdge: tKey,
|
||||
refRect,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bestV = vCandidates.length > 0
|
||||
? vCandidates.reduce((best, c) => c.distance < best.distance ? c : best)
|
||||
: null
|
||||
const bestH = hCandidates.length > 0
|
||||
? hCandidates.reduce((best, c) => c.distance < best.distance ? c : best)
|
||||
: null
|
||||
|
||||
const matchedV = bestV ? vCandidates.filter(c => c.refLine === bestV.refLine) : []
|
||||
const matchedH = bestH ? hCandidates.filter(c => c.refLine === bestH.refLine) : []
|
||||
|
||||
return { bestV, bestH, matchedV, matchedH }
|
||||
}
|
||||
|
||||
function buildGuideLines(
|
||||
matchedV: SnapCandidate[],
|
||||
matchedH: SnapCandidate[],
|
||||
snappedRect: Rect,
|
||||
): GuideLine[] {
|
||||
const guides: GuideLine[] = []
|
||||
|
||||
for (const m of matchedV) {
|
||||
const start = Math.min(snappedRect.y, m.refRect.y)
|
||||
const end = Math.max(snappedRect.y + snappedRect.height, m.refRect.y + m.refRect.height)
|
||||
guides.push({
|
||||
type: m.type,
|
||||
direction: 'vertical',
|
||||
position: m.refLine,
|
||||
start,
|
||||
end,
|
||||
})
|
||||
}
|
||||
|
||||
for (const m of matchedH) {
|
||||
const start = Math.min(snappedRect.x, m.refRect.x)
|
||||
const end = Math.max(snappedRect.x + snappedRect.width, m.refRect.x + m.refRect.width)
|
||||
guides.push({
|
||||
type: m.type,
|
||||
direction: 'horizontal',
|
||||
position: m.refLine,
|
||||
start,
|
||||
end,
|
||||
})
|
||||
}
|
||||
|
||||
// 去重(相同 direction + position 只保留范围最大的)
|
||||
const deduped = new Map<string, GuideLine>()
|
||||
for (const g of guides) {
|
||||
const key = `${g.direction}-${g.position}`
|
||||
const existing = deduped.get(key)
|
||||
if (!existing) {
|
||||
deduped.set(key, g)
|
||||
}
|
||||
else {
|
||||
existing.start = Math.min(existing.start, g.start)
|
||||
existing.end = Math.max(existing.end, g.end)
|
||||
}
|
||||
}
|
||||
|
||||
return [...deduped.values()]
|
||||
}
|
||||
|
||||
// ---- 等间距吸附 ----
|
||||
|
||||
function hasOverlap(a: Rect, b: Rect, axis: 'horizontal' | 'vertical'): boolean {
|
||||
if (axis === 'horizontal') {
|
||||
return a.y < b.y + b.height && a.y + a.height > b.y
|
||||
}
|
||||
return a.x < b.x + b.width && a.x + a.width > b.x
|
||||
}
|
||||
|
||||
function calcEqualSpacing(
|
||||
targetRect: Rect,
|
||||
otherRects: Rect[],
|
||||
threshold: number,
|
||||
): { deltaX: number, deltaY: number, distances: DistanceLabel[] } {
|
||||
let deltaX = 0
|
||||
let deltaY = 0
|
||||
const distances: DistanceLabel[] = []
|
||||
|
||||
// 水平等间距
|
||||
const hNeighbors = otherRects.filter(r => hasOverlap(r, targetRect, 'horizontal'))
|
||||
const leftNeighbor = hNeighbors
|
||||
.filter(r => r.x + r.width <= targetRect.x)
|
||||
.sort((a, b) => (b.x + b.width) - (a.x + a.width))[0]
|
||||
const rightNeighbor = hNeighbors
|
||||
.filter(r => r.x >= targetRect.x + targetRect.width)
|
||||
.sort((a, b) => a.x - b.x)[0]
|
||||
|
||||
if (leftNeighbor && rightNeighbor) {
|
||||
const totalGap = rightNeighbor.x - (leftNeighbor.x + leftNeighbor.width) - targetRect.width
|
||||
const equalX = leftNeighbor.x + leftNeighbor.width + totalGap / 2
|
||||
if (Math.abs(targetRect.x - equalX) <= threshold) {
|
||||
deltaX = equalX - targetRect.x
|
||||
const leftGap = Math.round(equalX - (leftNeighbor.x + leftNeighbor.width))
|
||||
const rightGap = Math.round(rightNeighbor.x - (equalX + targetRect.width))
|
||||
const midY = targetRect.y + targetRect.height / 2
|
||||
distances.push(
|
||||
{ direction: 'horizontal', x: leftNeighbor.x + leftNeighbor.width + leftGap / 2, y: midY, distance: leftGap },
|
||||
{ direction: 'horizontal', x: equalX + targetRect.width + rightGap / 2, y: midY, distance: rightGap },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 垂直等间距
|
||||
const vNeighbors = otherRects.filter(r => hasOverlap(r, targetRect, 'vertical'))
|
||||
const topNeighbor = vNeighbors
|
||||
.filter(r => r.y + r.height <= targetRect.y)
|
||||
.sort((a, b) => (b.y + b.height) - (a.y + a.height))[0]
|
||||
const bottomNeighbor = vNeighbors
|
||||
.filter(r => r.y >= targetRect.y + targetRect.height)
|
||||
.sort((a, b) => a.y - b.y)[0]
|
||||
|
||||
if (topNeighbor && bottomNeighbor) {
|
||||
const totalGap = bottomNeighbor.y - (topNeighbor.y + topNeighbor.height) - targetRect.height
|
||||
const equalY = topNeighbor.y + topNeighbor.height + totalGap / 2
|
||||
if (Math.abs(targetRect.y - equalY) <= threshold) {
|
||||
deltaY = equalY - targetRect.y
|
||||
const topGap = Math.round(equalY - (topNeighbor.y + topNeighbor.height))
|
||||
const bottomGap = Math.round(bottomNeighbor.y - (equalY + targetRect.height))
|
||||
const midX = targetRect.x + targetRect.width / 2
|
||||
distances.push(
|
||||
{ direction: 'vertical', x: midX, y: topNeighbor.y + topNeighbor.height + topGap / 2, distance: topGap },
|
||||
{ direction: 'vertical', x: midX, y: equalY + targetRect.height + bottomGap / 2, distance: bottomGap },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { deltaX, deltaY, distances }
|
||||
}
|
||||
|
||||
// ---- 公开 API ----
|
||||
|
||||
function snapToGridFallback(value: number, gridSize = 10, threshold = 4) {
|
||||
if (gridSize <= 0) {
|
||||
return value
|
||||
}
|
||||
const snapped = Math.round(value / gridSize) * gridSize
|
||||
return Math.abs(snapped - value) <= threshold ? snapped : value
|
||||
}
|
||||
|
||||
export function calcSnap(
|
||||
targetRect: Rect,
|
||||
otherRects: Rect[],
|
||||
canvasSize: { width: number, height: number },
|
||||
threshold = SNAP_THRESHOLD,
|
||||
): SnapResult {
|
||||
const canvasRect: Rect = { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height }
|
||||
const { bestV, bestH, matchedV, matchedH } = findBestSnap(targetRect, otherRects, canvasRect, threshold)
|
||||
|
||||
let snappedX = targetRect.x
|
||||
let snappedY = targetRect.y
|
||||
let guides: GuideLine[] = []
|
||||
let distances: DistanceLabel[] = []
|
||||
|
||||
const hasVSnap = bestV !== null
|
||||
const hasHSnap = bestH !== null
|
||||
|
||||
if (hasVSnap && bestV) {
|
||||
snappedX = targetRect.x + (bestV.refLine - bestV.targetLine)
|
||||
}
|
||||
if (hasHSnap && bestH) {
|
||||
snappedY = targetRect.y + (bestH.refLine - bestH.targetLine)
|
||||
}
|
||||
|
||||
const snappedRect: Rect = { x: snappedX, y: snappedY, width: targetRect.width, height: targetRect.height }
|
||||
guides = buildGuideLines(matchedV, matchedH, snappedRect)
|
||||
|
||||
// 等间距吸附:仅在对应方向无边缘/中线匹配时检测
|
||||
if (!hasVSnap || !hasHSnap) {
|
||||
const eqRect: Rect = { x: snappedX, y: snappedY, width: targetRect.width, height: targetRect.height }
|
||||
const eq = calcEqualSpacing(eqRect, otherRects, threshold)
|
||||
if (!hasVSnap && eq.deltaX !== 0) {
|
||||
snappedX += eq.deltaX
|
||||
}
|
||||
if (!hasHSnap && eq.deltaY !== 0) {
|
||||
snappedY += eq.deltaY
|
||||
}
|
||||
distances = eq.distances
|
||||
}
|
||||
|
||||
// Fallback 到网格吸附
|
||||
// 水平等间距的 distances 标记为 'horizontal',影响 X 坐标;垂直同理
|
||||
if (!hasVSnap && distances.filter(d => d.direction === 'horizontal').length === 0) {
|
||||
snappedX = snapToGridFallback(snappedX)
|
||||
}
|
||||
if (!hasHSnap && distances.filter(d => d.direction === 'vertical').length === 0) {
|
||||
snappedY = snapToGridFallback(snappedY)
|
||||
}
|
||||
|
||||
return { snappedX, snappedY, guides, distances }
|
||||
}
|
||||
|
||||
export function calcResizeSnap(
|
||||
edges: ('left' | 'right' | 'top' | 'bottom')[],
|
||||
currentRect: Rect,
|
||||
otherRects: Rect[],
|
||||
canvasSize: { width: number, height: number },
|
||||
threshold = SNAP_THRESHOLD,
|
||||
): ResizeSnapResult {
|
||||
const canvasRect: Rect = { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height }
|
||||
const allRefs: Array<{ rect: Rect, isCanvas: boolean }> = [
|
||||
...otherRects.map(r => ({ rect: r, isCanvas: false })),
|
||||
{ rect: canvasRect, isCanvas: true },
|
||||
]
|
||||
|
||||
const snappedEdges: Partial<Record<'left' | 'right' | 'top' | 'bottom', number>> = {}
|
||||
const guides: GuideLine[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
let edgePos: number
|
||||
if (edge === 'left') {
|
||||
edgePos = currentRect.x
|
||||
}
|
||||
else if (edge === 'right') {
|
||||
edgePos = currentRect.x + currentRect.width
|
||||
}
|
||||
else if (edge === 'top') {
|
||||
edgePos = currentRect.y
|
||||
}
|
||||
else {
|
||||
edgePos = currentRect.y + currentRect.height
|
||||
}
|
||||
|
||||
const isVertical = edge === 'left' || edge === 'right'
|
||||
let bestDist = threshold + 1
|
||||
let bestRef: number | null = null
|
||||
let bestType: GuideLine['type'] = 'edge'
|
||||
let bestRefRect: Rect | null = null
|
||||
|
||||
for (const { rect: refRect, isCanvas } of allRefs) {
|
||||
const refLines = isVertical
|
||||
? [refRect.x, refRect.x + refRect.width, refRect.x + refRect.width / 2]
|
||||
: [refRect.y, refRect.y + refRect.height, refRect.y + refRect.height / 2]
|
||||
|
||||
for (let i = 0; i < refLines.length; i++) {
|
||||
const dist = Math.abs(edgePos - refLines[i])
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
bestRef = refLines[i]
|
||||
bestType = isCanvas ? 'canvas' : (i === 2 ? 'center' : 'edge')
|
||||
bestRefRect = refRect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestRef !== null && bestRefRect) {
|
||||
snappedEdges[edge] = bestRef
|
||||
if (isVertical) {
|
||||
guides.push({
|
||||
type: bestType,
|
||||
direction: 'vertical',
|
||||
position: bestRef,
|
||||
start: Math.min(currentRect.y, bestRefRect.y),
|
||||
end: Math.max(currentRect.y + currentRect.height, bestRefRect.y + bestRefRect.height),
|
||||
})
|
||||
}
|
||||
else {
|
||||
guides.push({
|
||||
type: bestType,
|
||||
direction: 'horizontal',
|
||||
position: bestRef,
|
||||
start: Math.min(currentRect.x, bestRefRect.x),
|
||||
end: Math.max(currentRect.x + currentRect.width, bestRefRect.x + bestRefRect.width),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { snappedEdges, guides, distances: [] }
|
||||
}
|
||||
|
||||
export function useSnapGuides() {
|
||||
const snapGuides = ref<SnapGuidesState>({ guides: [], distances: [] })
|
||||
|
||||
function updateGuides(state: SnapGuidesState) {
|
||||
snapGuides.value = state
|
||||
}
|
||||
|
||||
function clearGuides() {
|
||||
snapGuides.value = { guides: [], distances: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
snapGuides,
|
||||
updateGuides,
|
||||
clearGuides,
|
||||
calcSnap,
|
||||
calcResizeSnap,
|
||||
}
|
||||
}
|
||||
1004
packages/core/src/components/editor/canvas/modules/stage/index.vue
Normal file
1004
packages/core/src/components/editor/canvas/modules/stage/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentTemplate } from '@/composables/useComponentTemplates'
|
||||
import { ref } from 'vue'
|
||||
import { useComponentTemplates } from '@/composables/useComponentTemplates'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'dragStart', event: DragEvent, template: ComponentTemplate): void
|
||||
(e: 'dragEnd', event: DragEvent): void
|
||||
}>()
|
||||
|
||||
const { templates, removeTemplate, renameTemplate } = useComponentTemplates()
|
||||
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingName = ref('')
|
||||
|
||||
function startRename(template: ComponentTemplate) {
|
||||
editingId.value = template.id
|
||||
editingName.value = template.name
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (editingId.value && editingName.value.trim()) {
|
||||
renameTemplate(editingId.value, editingName.value)
|
||||
}
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
async function confirmRemove(template: ComponentTemplate) {
|
||||
try {
|
||||
const { ElMessageBox } = await import('element-plus')
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除模板"${template.name}"吗?`,
|
||||
'删除模板',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
removeTemplate(template.id)
|
||||
}
|
||||
catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, template: ComponentTemplate) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: '__template__',
|
||||
templateId: template.id,
|
||||
}))
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
emit('dragStart', event, template)
|
||||
}
|
||||
|
||||
function onDragEnd(event: DragEvent) {
|
||||
emit('dragEnd', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-library">
|
||||
<div v-if="!templates.length" class="template-empty">
|
||||
<i class="fa-regular fa-bookmark" />
|
||||
<span>暂无模板</span>
|
||||
<span class="template-empty-hint">选中组件后右键"保存为模板"</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="template-list">
|
||||
<div
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
class="template-card"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, tpl)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="template-card__preview">
|
||||
<div
|
||||
v-for="layer in tpl.layers.slice(0, 6)"
|
||||
:key="layer.id"
|
||||
class="template-card__layer"
|
||||
:style="{
|
||||
background: layer.config?.fillColor ?? '#D7EBFF',
|
||||
}"
|
||||
/>
|
||||
<span v-if="tpl.layers.length > 6" class="template-card__more">
|
||||
+{{ tpl.layers.length - 6 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="template-card__info">
|
||||
<template v-if="editingId === tpl.id">
|
||||
<input
|
||||
v-model="editingName"
|
||||
class="template-rename-input"
|
||||
@keydown.enter="confirmRename"
|
||||
@blur="confirmRename"
|
||||
@keydown.escape="editingId = null"
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="template-card__name" @dblclick="startRename(tpl)">
|
||||
{{ tpl.name }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="template-card__count">{{ tpl.layers.length }} 个图层</span>
|
||||
</div>
|
||||
<div class="template-card__actions">
|
||||
<button class="template-action-btn" title="重命名" @click.stop="startRename(tpl)">
|
||||
<i class="fa-solid fa-pen" />
|
||||
</button>
|
||||
<button class="template-action-btn template-action-danger" title="删除" @click.stop="confirmRemove(tpl)">
|
||||
<i class="fa-solid fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-library {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.template-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 32px 16px;
|
||||
color: var(--ed-text-tertiary, #8c8c8c);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.template-empty i {
|
||||
font-size: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.template-empty-hint {
|
||||
font-size: 11px;
|
||||
color: var(--ed-text-disabled, #bfbfbf);
|
||||
}
|
||||
|
||||
.template-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--ed-radius-md, 6px);
|
||||
border: 1px solid var(--ed-border, #f0f0f0);
|
||||
background: var(--ed-bg-raised, #fafafa);
|
||||
cursor: grab;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: var(--ed-accent, #1677ff);
|
||||
box-shadow: 0 1px 4px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.template-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.template-card__preview {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.template-card__layer {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.template-card__more {
|
||||
font-size: 10px;
|
||||
color: var(--ed-text-tertiary, #8c8c8c);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.template-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.template-card__name {
|
||||
font-size: 12px;
|
||||
color: var(--ed-text-primary, #262626);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.template-card__count {
|
||||
font-size: 10px;
|
||||
color: var(--ed-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
.template-rename-input {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--ed-accent, #1677ff);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
background: var(--ed-bg-base, #fff);
|
||||
color: var(--ed-text-primary, #262626);
|
||||
}
|
||||
|
||||
.template-card__actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.template-card:hover .template-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.template-action-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
color: var(--ed-text-tertiary, #8c8c8c);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.template-action-btn:hover {
|
||||
background: var(--ed-bg-hover, #f0f0f0);
|
||||
color: var(--ed-text-primary, #262626);
|
||||
}
|
||||
|
||||
.template-action-danger:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
18
packages/core/src/components/editor/canvas/types.ts
Normal file
18
packages/core/src/components/editor/canvas/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// 核心数据模型从 schema 重新导出,保持内部导入路径兼容
|
||||
export type { CanvasView, Layer, LayerBindingDefinition, LayerBindingValue, LayerGroup } from '@cslab-dcs/schema'
|
||||
export type { ComponentEvent } from '@cslab-dcs/schema'
|
||||
|
||||
// 编辑器专有类型
|
||||
|
||||
export interface CanvasStageExpose {
|
||||
wrapperRef: Ref<HTMLDivElement | null>
|
||||
canvasRef: Ref<HTMLCanvasElement | null>
|
||||
}
|
||||
|
||||
export interface PropertyPanelExpose {
|
||||
panelRef: Ref<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
116
packages/core/src/components/editor/canvas/utils.ts
Normal file
116
packages/core/src/components/editor/canvas/utils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export function snapToGrid(value: number, gridSize = 10, threshold = 4) {
|
||||
if (gridSize <= 0) {
|
||||
return value
|
||||
}
|
||||
// 只在接近网格线时吸附,避免强制对齐导致拖拽手感生硬。
|
||||
const snapped = Math.round(value / gridSize) * gridSize
|
||||
return Math.abs(snapped - value) <= threshold ? snapped : value
|
||||
}
|
||||
|
||||
export interface RectLike {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function normalizeRect(startX: number, startY: number, endX: number, endY: number): RectLike {
|
||||
const x = Math.min(startX, endX)
|
||||
const y = Math.min(startY, endY)
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.abs(endX - startX),
|
||||
height: Math.abs(endY - startY),
|
||||
}
|
||||
}
|
||||
|
||||
export function rectIntersects(a: RectLike, b: RectLike) {
|
||||
return a.x < b.x + b.width
|
||||
&& a.x + a.width > b.x
|
||||
&& a.y < b.y + b.height
|
||||
&& a.y + a.height > b.y
|
||||
}
|
||||
|
||||
export function cloneValue<T>(value: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
try {
|
||||
return structuredClone(value)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
return safeJsonClone(value)
|
||||
}
|
||||
|
||||
function safeJsonClone<T>(value: T): T {
|
||||
const seen = new WeakSet<object>()
|
||||
// 兜底克隆时主动过滤 DOM、循环引用和不可序列化值,保证编辑器数据可复制。
|
||||
const serialized = JSON.stringify(value, (_key, current) => {
|
||||
if (typeof current === 'function' || typeof current === 'symbol') {
|
||||
return undefined
|
||||
}
|
||||
if (typeof current === 'bigint') {
|
||||
return Number(current)
|
||||
}
|
||||
if (typeof window !== 'undefined' && current === window) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof Node !== 'undefined' && current instanceof Node) {
|
||||
return undefined
|
||||
}
|
||||
if (current && typeof current === 'object') {
|
||||
if (seen.has(current)) {
|
||||
return undefined
|
||||
}
|
||||
seen.add(current)
|
||||
}
|
||||
return current
|
||||
})
|
||||
|
||||
if (serialized === undefined) {
|
||||
return value
|
||||
}
|
||||
return JSON.parse(serialized) as T
|
||||
}
|
||||
|
||||
// 统一处理暴露的 DOM 引用(可能是 ref,也可能已是元素本身)。
|
||||
export function unwrapElement<T extends HTMLElement | HTMLCanvasElement>(target: unknown): T | null {
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
if (typeof target === 'object' && 'value' in (target as { value?: unknown })) {
|
||||
return (target as { value?: T | null }).value ?? null
|
||||
}
|
||||
return target as T
|
||||
}
|
||||
|
||||
// 将图片缩放到 1x1 像素取样,作为“平均色”的近似值。
|
||||
export function sampleAverageColor(image: HTMLImageElement, fallback = 'rgb(245, 247, 250)') {
|
||||
const sampleCanvas = document.createElement('canvas')
|
||||
sampleCanvas.width = 1
|
||||
sampleCanvas.height = 1
|
||||
const ctx = sampleCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
return fallback
|
||||
}
|
||||
ctx.drawImage(image, 0, 0, 1, 1)
|
||||
const [red, green, blue, alpha] = ctx.getImageData(0, 0, 1, 1).data
|
||||
if (alpha === 0) {
|
||||
return fallback
|
||||
}
|
||||
return `rgb(${red}, ${green}, ${blue})`
|
||||
}
|
||||
|
||||
export function createLayerId() {
|
||||
const layerIndex = Math.floor(Math.random() * 100) + 1
|
||||
return `layer-${Date.now()}-${layerIndex}`
|
||||
}
|
||||
|
||||
export function createGroupId() {
|
||||
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
||||
return `group-${Date.now()}-${randomSuffix}`
|
||||
}
|
||||
148
packages/core/src/components/editor/components/bar.vue
Normal file
148
packages/core/src/components/editor/components/bar.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useCanvasRuntimeInject } from '../canvas/context'
|
||||
import { resolveLayerBindingValue } from './runtime'
|
||||
import { useEditorComponentLayerInject } from './types'
|
||||
|
||||
const { layer, resolved } = useEditorComponentLayerInject()
|
||||
const { variableMap } = useCanvasRuntimeInject()
|
||||
|
||||
const config = computed(() => layer.value.config ?? {})
|
||||
|
||||
const value = computed(() => {
|
||||
const bound = resolveLayerBindingValue(layer.value, 'config.value', variableMap.value)
|
||||
return typeof bound === 'number' ? bound : (config.value.value ?? 0)
|
||||
})
|
||||
|
||||
const min = computed(() => {
|
||||
const bound = resolveLayerBindingValue(layer.value, 'config.min', variableMap.value)
|
||||
return typeof bound === 'number' ? bound : (config.value.min ?? 0)
|
||||
})
|
||||
|
||||
const max = computed(() => {
|
||||
const bound = resolveLayerBindingValue(layer.value, 'config.max', variableMap.value)
|
||||
return typeof bound === 'number' ? bound : (config.value.max ?? 100)
|
||||
})
|
||||
|
||||
const direction = computed<'vertical' | 'horizontal'>(() => config.value.direction ?? 'vertical')
|
||||
const showValue = computed(() => config.value.showValue ?? true)
|
||||
|
||||
const percentage = computed(() => {
|
||||
const range = max.value - min.value
|
||||
if (range <= 0)
|
||||
return 0
|
||||
return Math.max(0, Math.min(100, ((value.value - min.value) / range) * 100))
|
||||
})
|
||||
|
||||
const fillColor = computed(() => {
|
||||
const defaultColor = config.value.foregroundColor || '#00ff00'
|
||||
const colors: Array<{ threshold: number, color: string }> = config.value.colors
|
||||
if (!colors?.length) {
|
||||
return defaultColor
|
||||
}
|
||||
const sorted = colors.toSorted((a, b) => b.threshold - a.threshold)
|
||||
for (const item of sorted) {
|
||||
if (percentage.value >= item.threshold) {
|
||||
return item.color
|
||||
}
|
||||
}
|
||||
return defaultColor
|
||||
})
|
||||
|
||||
const wrapperStyle = computed<CSSProperties>(() => {
|
||||
const style: CSSProperties = {
|
||||
borderColor: resolved.value.borderColor || 'transparent',
|
||||
borderWidth: `${resolved.value.borderWidth}px`,
|
||||
borderStyle: resolved.value.borderWidth > 0 ? resolved.value.borderStyle : 'solid',
|
||||
borderRadius: `${resolved.value.borderRadius}px`,
|
||||
boxShadow: resolved.value.boxShadow,
|
||||
background: resolved.value.backgroundColor || '#f0f0f0',
|
||||
}
|
||||
const img = resolved.value.fillImage
|
||||
if (img?.src && (img.src.startsWith('data:image/') || img.src.startsWith('http'))) {
|
||||
const fitMap: Record<string, string> = { cover: 'cover', contain: 'contain', fill: '100% 100%' }
|
||||
style.backgroundColor = 'transparent'
|
||||
style.backgroundImage = `url("${img.src.replaceAll('"', '')}")`
|
||||
style.backgroundSize = fitMap[img.fit || 'cover'] || 'cover'
|
||||
style.backgroundPosition = 'center'
|
||||
style.backgroundRepeat = 'no-repeat'
|
||||
if (img.opacity !== undefined && img.opacity < 1)
|
||||
style.opacity = img.opacity
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
const barStyle = computed<CSSProperties>(() => {
|
||||
const isVertical = direction.value === 'vertical'
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
bottom: isVertical ? '0' : '0',
|
||||
top: isVertical ? 'auto' : '0',
|
||||
right: isVertical ? '0' : 'auto',
|
||||
width: isVertical ? '100%' : `${percentage.value}%`,
|
||||
height: isVertical ? `${percentage.value}%` : '100%',
|
||||
background: fillColor.value,
|
||||
borderRadius: 'inherit',
|
||||
transition: 'width 0.3s ease, height 0.3s ease, background 0.3s ease',
|
||||
}
|
||||
})
|
||||
|
||||
// 数值内容:优先取绑定值,其次取 config.valueContent,最后回退百分比。
|
||||
const displayValue = computed(() => {
|
||||
const bound = resolveLayerBindingValue(layer.value, 'config.valueContent', variableMap.value)
|
||||
if (bound !== undefined && bound !== null && bound !== '') {
|
||||
return String(bound)
|
||||
}
|
||||
const content = config.value.valueContent
|
||||
if (content) {
|
||||
return content
|
||||
}
|
||||
return `${Math.round(percentage.value)}%`
|
||||
})
|
||||
|
||||
const valueStyle = computed<CSSProperties>(() => ({
|
||||
color: config.value.valueColor ?? '#ffffff',
|
||||
fontSize: `${config.value.valueFontSize ?? 14}px`,
|
||||
fontWeight: config.value.valueFontWeight ?? 600,
|
||||
textAlign: 'center',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layer-bar" :style="wrapperStyle">
|
||||
<div class="layer-bar__track">
|
||||
<div class="layer-bar__fill" :style="barStyle" />
|
||||
</div>
|
||||
<div v-if="showValue" class="layer-bar__value" :style="valueStyle">
|
||||
{{ displayValue }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layer-bar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
}
|
||||
.layer-bar__track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.layer-bar__value {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
123
packages/core/src/components/editor/components/button.vue
Normal file
123
packages/core/src/components/editor/components/button.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, nextTick, shallowRef, watch } from 'vue'
|
||||
import { useCanvasRuntimeInject } from '../canvas/context'
|
||||
import { setEditorEmitter } from '../canvas/emitter'
|
||||
import { resolveLayerBindingValue } from './runtime'
|
||||
import { useEditorComponentLayerInject } from './types'
|
||||
|
||||
const { layer, resolved, editing, onEditComplete } = useEditorComponentLayerInject()
|
||||
const { variableMap } = useCanvasRuntimeInject()
|
||||
|
||||
const config = computed(() => layer.value.config ?? {})
|
||||
|
||||
const label = computed(() => {
|
||||
const bound = resolveLayerBindingValue(layer.value, 'config.label', variableMap.value)
|
||||
return typeof bound === 'string' ? bound : (config.value.label ?? '按钮')
|
||||
})
|
||||
|
||||
const bgColor = computed(() => resolved.value.backgroundColor || '#1677ff')
|
||||
const textColor = computed(() => resolved.value.textColor || '#ffffff')
|
||||
|
||||
const wrapperStyle = computed<CSSProperties>(() => {
|
||||
const style: CSSProperties = {
|
||||
background: bgColor.value,
|
||||
color: textColor.value,
|
||||
borderColor: resolved.value.borderColor || 'transparent',
|
||||
borderWidth: `${resolved.value.borderWidth}px`,
|
||||
borderStyle: resolved.value.borderWidth > 0 ? resolved.value.borderStyle : 'solid',
|
||||
borderRadius: `${resolved.value.borderRadius || 4}px`,
|
||||
boxShadow: resolved.value.boxShadow,
|
||||
fontSize: `${resolved.value.fontSize || 14}px`,
|
||||
fontWeight: String(resolved.value.fontWeight || 500),
|
||||
cursor: 'default',
|
||||
}
|
||||
const img = resolved.value.fillImage
|
||||
if (img?.src && (img.src.startsWith('data:image/') || img.src.startsWith('http'))) {
|
||||
const fitMap: Record<string, string> = { cover: 'cover', contain: 'contain', fill: '100% 100%' }
|
||||
style.backgroundColor = 'transparent'
|
||||
style.backgroundImage = `url("${img.src.replaceAll('"', '')}")`
|
||||
style.backgroundSize = fitMap[img.fit || 'cover'] || 'cover'
|
||||
style.backgroundPosition = 'center'
|
||||
style.backgroundRepeat = 'no-repeat'
|
||||
if (img.opacity !== undefined && img.opacity < 1)
|
||||
style.opacity = img.opacity
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
// ── 内联编辑 ──
|
||||
const editRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
watch(() => editing.value, (isEditing) => {
|
||||
if (isEditing) {
|
||||
nextTick(() => {
|
||||
if (!editRef.value)
|
||||
return
|
||||
editRef.value.textContent = String(layer.value.config?.label ?? '按钮')
|
||||
editRef.value.focus()
|
||||
const sel = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editRef.value)
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function onBlur() {
|
||||
const text = editRef.value?.textContent ?? ''
|
||||
setEditorEmitter('layer:updateField', { id: layer.value.id, field: 'config.label', value: text || '按钮' })
|
||||
onEditComplete()
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Escape' || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
editRef.value?.blur()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layer-button" :style="wrapperStyle">
|
||||
<span v-if="!editing" class="layer-button__label">{{ label }}</span>
|
||||
<span
|
||||
v-else
|
||||
ref="editRef"
|
||||
contenteditable
|
||||
class="layer-button__label is-editing"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeyDown"
|
||||
@pointerdown.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layer-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.layer-button__label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.layer-button__label.is-editing {
|
||||
pointer-events: auto;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
outline: none;
|
||||
min-width: 1em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useCanvasState } from '../canvas/context/state'
|
||||
import { useEditorComponentLayerInject } from './types'
|
||||
|
||||
const { layer, resolved } = useEditorComponentLayerInject()
|
||||
const { canvasList } = useCanvasState()
|
||||
const config = computed(() => layer.value.config ?? {})
|
||||
|
||||
const items = computed<Array<{ canvasId: string }>>(() => config.value.items ?? [])
|
||||
|
||||
function resolveCanvasName(canvasId: string) {
|
||||
if (!canvasId)
|
||||
return '未选择'
|
||||
return canvasList.value?.find(c => c.id === canvasId)?.name ?? '未知画布'
|
||||
}
|
||||
const activeColor = computed(() => config.value.activeColor ?? '#E1E1E1')
|
||||
const inactiveColor = computed(() => config.value.inactiveColor ?? '#C8C8C8')
|
||||
const activeTextColor = computed(() => config.value.activeTextColor ?? '#333333')
|
||||
const inactiveTextColor = computed(() => config.value.inactiveTextColor ?? '#333333')
|
||||
|
||||
const activeIndex = computed(() => 0)
|
||||
|
||||
const wrapperStyle = computed<CSSProperties>(() => ({
|
||||
borderColor: resolved.value.borderColor || 'transparent',
|
||||
borderWidth: `${resolved.value.borderWidth}px`,
|
||||
borderStyle: resolved.value.borderWidth > 0 ? resolved.value.borderStyle : 'solid',
|
||||
borderRadius: `${resolved.value.borderRadius}px`,
|
||||
boxShadow: resolved.value.boxShadow,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layer-canvas-switcher" :style="wrapperStyle">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="canvas-tab"
|
||||
:style="{
|
||||
background: index === activeIndex ? activeColor : inactiveColor,
|
||||
color: index === activeIndex ? activeTextColor : inactiveTextColor,
|
||||
}"
|
||||
>
|
||||
{{ resolveCanvasName(item.canvasId) }}
|
||||
</div>
|
||||
<div v-if="!items.length" class="canvas-tab-empty">
|
||||
切页按钮
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layer-canvas-switcher {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.canvas-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 8px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.canvas-tab-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 表达式安全沙盒
|
||||
* 基于 AST 白名单解释器,替代了不安全的 new Function() 实现
|
||||
*/
|
||||
|
||||
import { evaluate, validate } from './expression'
|
||||
|
||||
export function isExpressionSafe(expression: string): boolean {
|
||||
return validate(expression).ok
|
||||
}
|
||||
|
||||
export function safeEvaluate(expression: string, scope: Record<string, unknown>): unknown {
|
||||
if (!isExpressionSafe(expression)) {
|
||||
console.error('[expression-sandbox] 表达式校验失败,已拒绝执行:', expression)
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return evaluate(expression, scope)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[expression-sandbox] 表达式求值失败:', expression, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// AST 求值器 — 基于白名单的安全求值
|
||||
|
||||
import type { ASTNode } from './types'
|
||||
import { parse } from './parser'
|
||||
|
||||
// 最大递归深度
|
||||
const MAX_DEPTH = 50
|
||||
|
||||
// 禁止访问的属性名
|
||||
const FORBIDDEN_PROPERTIES = new Set([
|
||||
'constructor',
|
||||
'__proto__',
|
||||
'prototype',
|
||||
'__defineGetter__',
|
||||
'__defineSetter__',
|
||||
'__lookupGetter__',
|
||||
'__lookupSetter__',
|
||||
])
|
||||
|
||||
// 禁止作为标识符的名称
|
||||
const FORBIDDEN_IDENTIFIERS = new Set([
|
||||
'this',
|
||||
'globalThis',
|
||||
'window',
|
||||
'self',
|
||||
'global',
|
||||
'document',
|
||||
'eval',
|
||||
'Function',
|
||||
'import',
|
||||
'require',
|
||||
'process',
|
||||
'Proxy',
|
||||
'Reflect',
|
||||
'WeakRef',
|
||||
'FinalizationRegistry',
|
||||
'setTimeout',
|
||||
'setInterval',
|
||||
'fetch',
|
||||
'XMLHttpRequest',
|
||||
'WebSocket',
|
||||
'Worker',
|
||||
])
|
||||
|
||||
// 白名单:允许在 scope 中提供的全局对象及其方法
|
||||
const ALLOWED_METHODS: Record<string, Set<string>> = {
|
||||
Math: new Set(['abs', 'ceil', 'floor', 'round', 'min', 'max', 'pow', 'sqrt', 'log', 'sign', 'trunc', 'random']),
|
||||
Number: new Set(['isFinite', 'isInteger', 'isNaN', 'parseFloat', 'parseInt']),
|
||||
String: new Set(['fromCharCode']),
|
||||
Date: new Set(['now']),
|
||||
}
|
||||
|
||||
// Boolean 作为转换函数,允许直接调用
|
||||
const ALLOWED_CALLABLE = new Set(['Boolean'])
|
||||
|
||||
// 字符串实例方法白名单
|
||||
const STRING_METHODS = new Set([
|
||||
'trim',
|
||||
'toUpperCase',
|
||||
'toLowerCase',
|
||||
'indexOf',
|
||||
'includes',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'slice',
|
||||
'substring',
|
||||
'split',
|
||||
'replace',
|
||||
'charAt',
|
||||
])
|
||||
|
||||
// 数组实例方法白名单
|
||||
const ARRAY_METHODS = new Set([
|
||||
'indexOf',
|
||||
'includes',
|
||||
'join',
|
||||
'slice',
|
||||
'map',
|
||||
'filter',
|
||||
'find',
|
||||
'some',
|
||||
'every',
|
||||
])
|
||||
|
||||
// 数字实例方法白名单
|
||||
const NUMBER_METHODS = new Set(['toFixed', 'toString'])
|
||||
|
||||
// 字符串/数组只读属性白名单
|
||||
const ALLOWED_READONLY_PROPS = new Set(['length'])
|
||||
|
||||
// 验证 String.fromCharCode 参数是否在可打印字符范围内
|
||||
function isAllowedCharCode(code: unknown): boolean {
|
||||
return typeof code === 'number' && code >= 32 && code <= 126
|
||||
}
|
||||
|
||||
// 检查属性访问是否安全
|
||||
function assertPropertySafe(prop: string): void {
|
||||
if (FORBIDDEN_PROPERTIES.has(prop)) {
|
||||
throw new Error(`禁止访问属性 '${prop}'`)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析成员表达式的 object 部分,判断是否是白名单对象
|
||||
function resolveCallTarget(
|
||||
node: ASTNode,
|
||||
scope: Record<string, unknown>,
|
||||
depth: number,
|
||||
): { target: unknown, methodName: string } | null {
|
||||
if (node.type !== 'MemberExpression')
|
||||
return null
|
||||
|
||||
const methodNode = node.computed ? null : node.property
|
||||
if (!methodNode || methodNode.type !== 'Identifier')
|
||||
return null
|
||||
|
||||
const methodName = methodNode.name
|
||||
assertPropertySafe(methodName)
|
||||
|
||||
const target = evaluateNode(node.object, scope, depth + 1)
|
||||
return { target, methodName }
|
||||
}
|
||||
|
||||
// 核心求值函数
|
||||
function evaluateNode(node: ASTNode, scope: Record<string, unknown>, depth: number): unknown {
|
||||
if (depth > MAX_DEPTH) {
|
||||
throw new Error('递归深度超过限制')
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case 'NumericLiteral':
|
||||
return node.value
|
||||
|
||||
case 'StringLiteral':
|
||||
return node.value
|
||||
|
||||
case 'BooleanLiteral':
|
||||
return node.value
|
||||
|
||||
case 'NullLiteral':
|
||||
return null
|
||||
|
||||
case 'UndefinedLiteral':
|
||||
return undefined
|
||||
|
||||
case 'Identifier': {
|
||||
if (FORBIDDEN_IDENTIFIERS.has(node.name)) {
|
||||
throw new Error(`禁止访问标识符 '${node.name}'`)
|
||||
}
|
||||
if (!(node.name in scope)) {
|
||||
throw new Error(`未定义的标识符 '${node.name}'`)
|
||||
}
|
||||
return scope[node.name]
|
||||
}
|
||||
|
||||
case 'ArrayExpression':
|
||||
return node.elements.map(el => evaluateNode(el, scope, depth + 1))
|
||||
|
||||
case 'BinaryExpression': {
|
||||
const left = evaluateNode(node.left, scope, depth + 1)
|
||||
const right = evaluateNode(node.right, scope, depth + 1)
|
||||
return evaluateBinaryOp(node.operator, left, right)
|
||||
}
|
||||
|
||||
case 'LogicalExpression': {
|
||||
const leftVal = evaluateNode(node.left, scope, depth + 1)
|
||||
if (node.operator === '&&') {
|
||||
return leftVal ? evaluateNode(node.right, scope, depth + 1) : leftVal
|
||||
}
|
||||
// ||
|
||||
return leftVal || evaluateNode(node.right, scope, depth + 1)
|
||||
}
|
||||
|
||||
case 'UnaryExpression': {
|
||||
const operand = evaluateNode(node.operand, scope, depth + 1)
|
||||
switch (node.operator) {
|
||||
case '!': return !operand
|
||||
case '-': return -(operand as number)
|
||||
case '+': return +(operand as number)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'TypeofExpression': {
|
||||
// typeof 对未定义标识符不抛错
|
||||
try {
|
||||
const val = evaluateNode(node.operand, scope, depth + 1)
|
||||
return typeof val
|
||||
}
|
||||
catch {
|
||||
return 'undefined'
|
||||
}
|
||||
}
|
||||
|
||||
case 'ConditionalExpression': {
|
||||
const test = evaluateNode(node.test, scope, depth + 1)
|
||||
return test
|
||||
? evaluateNode(node.consequent, scope, depth + 1)
|
||||
: evaluateNode(node.alternate, scope, depth + 1)
|
||||
}
|
||||
|
||||
case 'NullishCoalescing': {
|
||||
const leftResult = evaluateNode(node.left, scope, depth + 1)
|
||||
return leftResult ?? evaluateNode(node.right, scope, depth + 1)
|
||||
}
|
||||
|
||||
case 'MemberExpression': {
|
||||
const obj = evaluateNode(node.object, scope, depth + 1)
|
||||
const prop = resolveMemberProperty(node, scope, depth)
|
||||
const propStr = String(prop)
|
||||
|
||||
assertPropertySafe(propStr)
|
||||
|
||||
// null/undefined 不可访问
|
||||
if (obj === null || obj === undefined) {
|
||||
throw new Error(`无法访问 ${obj} 的属性 '${propStr}'`)
|
||||
}
|
||||
|
||||
// 对于白名单对象(Math 等),允许访问其白名单方法
|
||||
if (typeof obj === 'object' || typeof obj === 'function') {
|
||||
const objName = findScopeKeyForValue(scope, obj)
|
||||
if (objName && ALLOWED_METHODS[objName]) {
|
||||
if (ALLOWED_METHODS[objName].has(propStr)) {
|
||||
return (obj as Record<string, unknown>)[propStr]
|
||||
}
|
||||
throw new Error(`不允许访问 ${objName}.${propStr}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 字符串属性/方法访问
|
||||
if (typeof obj === 'string') {
|
||||
if (ALLOWED_READONLY_PROPS.has(propStr))
|
||||
return (obj as unknown as Record<string, unknown>)[propStr]
|
||||
if (STRING_METHODS.has(propStr))
|
||||
return (obj as unknown as Record<string, (...args: unknown[]) => unknown>)[propStr].bind(obj)
|
||||
throw new Error(`不允许访问字符串的 '${propStr}' 属性`)
|
||||
}
|
||||
|
||||
// 数组属性/方法访问
|
||||
if (Array.isArray(obj)) {
|
||||
if (ALLOWED_READONLY_PROPS.has(propStr))
|
||||
return (obj as unknown as Record<string, unknown>)[propStr]
|
||||
if (ARRAY_METHODS.has(propStr))
|
||||
return (obj as unknown as Record<string, (...args: unknown[]) => unknown>)[propStr].bind(obj)
|
||||
// 允许按数字索引访问数组元素
|
||||
const idx = Number(propStr)
|
||||
if (Number.isInteger(idx) && idx >= 0 && idx < obj.length) {
|
||||
return obj[idx]
|
||||
}
|
||||
throw new Error(`不允许访问数组的 '${propStr}' 属性`)
|
||||
}
|
||||
|
||||
// 数字属性/方法访问
|
||||
if (typeof obj === 'number') {
|
||||
if (NUMBER_METHODS.has(propStr))
|
||||
return (obj as unknown as Record<string, (...args: unknown[]) => unknown>)[propStr].bind(obj)
|
||||
throw new Error(`不允许访问数字的 '${propStr}' 属性`)
|
||||
}
|
||||
|
||||
// 普通对象属性访问(scope 中的用户数据对象)
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
if (Object.hasOwn(obj, propStr)) {
|
||||
return (obj as Record<string, unknown>)[propStr]
|
||||
}
|
||||
// 允许返回 undefined 而不是抛错,和 JS 行为一致
|
||||
return undefined
|
||||
}
|
||||
|
||||
throw new Error(`不允许在 ${typeof obj} 类型上访问属性 '${propStr}'`)
|
||||
}
|
||||
|
||||
case 'CallExpression': {
|
||||
return evaluateCall(node, scope, depth)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`未知的节点类型: ${(node as ASTNode).type}`)
|
||||
}
|
||||
|
||||
// 解析成员表达式的属性
|
||||
function resolveMemberProperty(node: ASTNode & { type: 'MemberExpression' }, scope: Record<string, unknown>, depth: number): unknown {
|
||||
if (node.computed) {
|
||||
return evaluateNode(node.property, scope, depth + 1)
|
||||
}
|
||||
if (node.property.type === 'Identifier') {
|
||||
return node.property.name
|
||||
}
|
||||
throw new Error('无效的成员表达式属性')
|
||||
}
|
||||
|
||||
// 在 scope 中查找某个值对应的 key
|
||||
function findScopeKeyForValue(scope: Record<string, unknown>, value: unknown): string | undefined {
|
||||
for (const key of Object.keys(scope)) {
|
||||
if (scope[key] === value)
|
||||
return key
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 函数调用求值
|
||||
function evaluateCall(node: ASTNode & { type: 'CallExpression' }, scope: Record<string, unknown>, depth: number): unknown {
|
||||
const args = node.arguments.map(arg => evaluateNode(arg, scope, depth + 1))
|
||||
|
||||
// 如果 callee 是成员表达式(如 Math.round(...))
|
||||
if (node.callee.type === 'MemberExpression') {
|
||||
const resolved = resolveCallTarget(node.callee, scope, depth)
|
||||
if (!resolved) {
|
||||
throw new Error('无效的函数调用')
|
||||
}
|
||||
|
||||
const { target, methodName } = resolved
|
||||
|
||||
// 白名单对象方法调用
|
||||
if (target !== null && target !== undefined) {
|
||||
const objName = findScopeKeyForValue(scope, target)
|
||||
|
||||
// String.fromCharCode 特殊处理
|
||||
if (objName === 'String' && methodName === 'fromCharCode') {
|
||||
if (!args.every(isAllowedCharCode)) {
|
||||
throw new Error('String.fromCharCode 参数超出允许的可打印字符范围')
|
||||
}
|
||||
return String.fromCharCode(...(args as number[]))
|
||||
}
|
||||
|
||||
// 白名单静态方法
|
||||
if (objName && ALLOWED_METHODS[objName]?.has(methodName)) {
|
||||
const method = (target as Record<string, (...a: unknown[]) => unknown>)[methodName]
|
||||
if (typeof method === 'function') {
|
||||
return method.apply(target, args)
|
||||
}
|
||||
}
|
||||
|
||||
// 字符串实例方法
|
||||
if (typeof target === 'string' && STRING_METHODS.has(methodName)) {
|
||||
const method = (target as unknown as Record<string, (...a: unknown[]) => unknown>)[methodName]
|
||||
return method.apply(target, args)
|
||||
}
|
||||
|
||||
// 数组实例方法
|
||||
if (Array.isArray(target) && ARRAY_METHODS.has(methodName)) {
|
||||
// 数组方法的回调参数安全性:确保回调是 scope 中的函数或简单的箭头函数 AST
|
||||
const method = (target as unknown as Record<string, (...a: unknown[]) => unknown>)[methodName]
|
||||
return method.apply(target, args)
|
||||
}
|
||||
|
||||
// 数字实例方法
|
||||
if (typeof target === 'number' && NUMBER_METHODS.has(methodName)) {
|
||||
const method = (target as unknown as Record<string, (...a: unknown[]) => unknown>)[methodName]
|
||||
return method.apply(target, args)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`不允许调用 ${methodName}`)
|
||||
}
|
||||
|
||||
// 直接函数调用(如 Boolean(...))
|
||||
if (node.callee.type === 'Identifier') {
|
||||
const fnName = node.callee.name
|
||||
|
||||
if (FORBIDDEN_IDENTIFIERS.has(fnName)) {
|
||||
throw new Error(`禁止调用 '${fnName}'`)
|
||||
}
|
||||
|
||||
if (ALLOWED_CALLABLE.has(fnName) && fnName in scope) {
|
||||
const fn = scope[fnName]
|
||||
if (typeof fn === 'function') {
|
||||
return fn(...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是 scope 中用户提供的函数
|
||||
if (fnName in scope) {
|
||||
const fn = scope[fnName]
|
||||
if (typeof fn === 'function') {
|
||||
// 只允许白名单函数
|
||||
const objName = findScopeKeyForValue(scope, fn)
|
||||
if (objName && ALLOWED_CALLABLE.has(objName)) {
|
||||
return fn(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`不允许调用函数 '${fnName}'`)
|
||||
}
|
||||
|
||||
throw new Error('不允许的函数调用形式')
|
||||
}
|
||||
|
||||
// 二元运算符求值
|
||||
function evaluateBinaryOp(op: string, left: unknown, right: unknown): unknown {
|
||||
switch (op) {
|
||||
case '+': return (left as number) + (right as number)
|
||||
case '-': return (left as number) - (right as number)
|
||||
case '*': return (left as number) * (right as number)
|
||||
case '/': return (left as number) / (right as number)
|
||||
case '%': return (left as number) % (right as number)
|
||||
case '===': return left === right
|
||||
case '!==': return left !== right
|
||||
case '==': return left == right // eslint-disable-line eqeqeq
|
||||
case '!=': return left != right // eslint-disable-line eqeqeq
|
||||
case '>': return (left as number) > (right as number)
|
||||
case '<': return (left as number) < (right as number)
|
||||
case '>=': return (left as number) >= (right as number)
|
||||
case '<=': return (left as number) <= (right as number)
|
||||
default: throw new Error(`未知的运算符 '${op}'`)
|
||||
}
|
||||
}
|
||||
|
||||
// 公开 API:求值
|
||||
export function evaluate(expression: string, scope: Record<string, unknown>): unknown {
|
||||
const ast = parse(expression)
|
||||
return evaluateNode(ast, scope, 0)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user