mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 15:05:27 +00:00
feat: unify node style schema and add full style editing panel
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 模块状态总览(重写)
|
# 模块状态总览(重写)
|
||||||
|
|
||||||
总体完成度(粗略):约 73%
|
总体完成度(粗略):约 75%
|
||||||
|
|
||||||
## 1. 画布(LogicFlow) — 完成度:75%
|
## 1. 画布(LogicFlow) — 完成度:75%
|
||||||
- 已完成:
|
- 已完成:
|
||||||
@@ -24,14 +24,15 @@
|
|||||||
- 点击快速创建、组件预览缩略图、搜索与分组折叠
|
- 点击快速创建、组件预览缩略图、搜索与分组折叠
|
||||||
- 外置配置(JSON)与动态加载,便于扩展
|
- 外置配置(JSON)与动态加载,便于扩展
|
||||||
|
|
||||||
## 3. 右侧属性面板(Inspector) — 完成度:70%
|
## 3. 右侧属性面板(Inspector) — 完成度:80%
|
||||||
- 已完成:
|
- 已完成:
|
||||||
- 按节点类型切换 UI,显示基本信息(ID/类型)(src/components/flow/PropertyPanel.vue),面板按节点类型拆分子组件
|
- 按节点类型切换 UI,显示基本信息(ID/类型)(src/components/flow/PropertyPanel.vue),面板按节点类型拆分子组件
|
||||||
- 打开式神/御魂/属性弹窗,并通过 `lf.setProperties` 回写到节点
|
- 打开式神/御魂/属性弹窗,并通过 `lf.setProperties` 回写到节点
|
||||||
- `imageNode` 属性编辑:URL/本地上传、fit、宽高与预览,写回 `properties` 同步渲染
|
- `imageNode` 属性编辑:URL/本地上传、fit、宽高与预览,写回 `properties` 同步渲染
|
||||||
|
- 样式模型:统一 `properties.style`,属性面板支持填充/描边/圆角/阴影/透明度/文字对齐/行高/字重,节点渲染消费样式
|
||||||
- 未完成:
|
- 未完成:
|
||||||
- `textNode` 富文本编辑与同步
|
- `textNode` 富文本编辑与同步
|
||||||
- 字段校验/联动、常用模板一键填充、更多样式项(填充/描边/圆角/阴影/透明度)
|
- 字段校验/联动、常用模板一键填充
|
||||||
|
|
||||||
## 4. 工具栏(Toolbar) — 完成度:80%
|
## 4. 工具栏(Toolbar) — 完成度:80%
|
||||||
- 已完成:
|
- 已完成:
|
||||||
@@ -107,7 +108,7 @@
|
|||||||
3) 图层命令 MVP:基于 LogicFlow 的层级/前后置 API 封装 bringToFront/sendToBack/bringForward/sendBackward + 右键菜单,如需持久化仅同步引擎提供的层级信息(`src/components/flow/FlowEditor.vue`)。已完成:置顶/置底 + 右键菜单;待补:单步前移/后移。
|
3) 图层命令 MVP:基于 LogicFlow 的层级/前后置 API 封装 bringToFront/sendToBack/bringForward/sendBackward + 右键菜单,如需持久化仅同步引擎提供的层级信息(`src/components/flow/FlowEditor.vue`)。已完成:置顶/置底 + 右键菜单;待补:单步前移/后移。
|
||||||
4) 多选/对齐/吸附:框选、对齐线、吸附网格;左/右/上/下/水平/垂直居中与横/纵等距分布(FlowEditor/extension)。已完成
|
4) 多选/对齐/吸附:框选、对齐线、吸附网格;左/右/上/下/水平/垂直居中与横/纵等距分布(FlowEditor/extension)。已完成
|
||||||
5) 快捷键与微调:Del 删除、方向键微移、Ctrl+C/V 复制粘贴、Ctrl+G/U 组/解组(简单组:父 meta id + 同步移动)、锁定/隐藏(`properties.locked`/`visible`)。
|
5) 快捷键与微调:Del 删除、方向键微移、Ctrl+C/V 复制粘贴、Ctrl+G/U 组/解组(简单组:父 meta id + 同步移动)、锁定/隐藏(`properties.locked`/`visible`)。
|
||||||
6) 样式模型补齐:统一 `properties.style` 字段并在 PropertyPanel 全量编辑(填充/描边/圆角/阴影/透明度/文字对齐/行高/字重)。
|
6) 样式模型补齐:统一 `properties.style` 字段并在 PropertyPanel 全量编辑(填充/描边/圆角/阴影/透明度/文字对齐/行高/字重)。【已完成】
|
||||||
7) 扩展与控制:接入 MiniMap/Control/Snapshot;Toolbar 增加吸附/对齐开关与清空画布。
|
7) 扩展与控制:接入 MiniMap/Control/Snapshot;Toolbar 增加吸附/对齐开关与清空画布。
|
||||||
8) 矢量节点 MVP:`vectorNode`(SVG path/rect/ellipse/polygon),属性面板支持 path/stroke/fill/strokeWidth;新增 SVG 导入弹窗。
|
8) 矢量节点 MVP:`vectorNode`(SVG path/rect/ellipse/polygon),属性面板支持 path/stroke/fill/strokeWidth;新增 SVG 导入弹窗。
|
||||||
9) 资源与导出增强:图片资源选择/上传弹窗(base64 或预留 `assetId`),导出 SVG/PDF(PDF 可延后)。
|
9) 资源与导出增强:图片资源选择/上传弹窗(base64 或预留 `assetId`),导出 SVG/PDF(PDF 可延后)。
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import ImageNode from './nodes/common/ImageNode.vue';
|
|||||||
import PropertyPanel from './PropertyPanel.vue';
|
import PropertyPanel from './PropertyPanel.vue';
|
||||||
import { useGlobalMessage } from '@/ts/useGlobalMessage';
|
import { useGlobalMessage } from '@/ts/useGlobalMessage';
|
||||||
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
|
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@/ts/nodeStyle';
|
||||||
|
|
||||||
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
||||||
type DistributeType = 'horizontal' | 'vertical';
|
type DistributeType = 'horizontal' | 'vertical';
|
||||||
@@ -152,22 +153,41 @@ function applyMetaToModel(model: BaseNodeModel, metaInput?: Record<string, any>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyStyleToModel(model: BaseNodeModel, styleInput?: Record<string, any>) {
|
||||||
|
const style = normalizeNodeStyle(styleInput, { width: model.width, height: model.height });
|
||||||
|
if (style.width && model.width !== style.width) {
|
||||||
|
model.width = style.width;
|
||||||
|
}
|
||||||
|
if (style.height && model.height !== style.height) {
|
||||||
|
model.height = style.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNodeModel(model: BaseNodeModel) {
|
function normalizeNodeModel(model: BaseNodeModel) {
|
||||||
const lfInstance = lf.value;
|
const lfInstance = lf.value;
|
||||||
if (!lfInstance) return;
|
if (!lfInstance) return;
|
||||||
|
|
||||||
const props = (model.getProperties?.() as any) ?? (model as any)?.properties ?? {};
|
const props = (model.getProperties?.() as any) ?? (model as any)?.properties ?? {};
|
||||||
const incomingMeta = ensureMeta(props.meta);
|
const incomingMeta = ensureMeta(props.meta);
|
||||||
|
const normalized = normalizePropertiesWithStyle(
|
||||||
|
{ ...props, meta: incomingMeta },
|
||||||
|
{ width: props.width ?? model.width, height: props.height ?? model.height }
|
||||||
|
);
|
||||||
const currentMeta = ensureMeta((model as any)?.properties?.meta);
|
const currentMeta = ensureMeta((model as any)?.properties?.meta);
|
||||||
const needPersist =
|
const metaChanged =
|
||||||
currentMeta.visible !== incomingMeta.visible ||
|
currentMeta.visible !== incomingMeta.visible ||
|
||||||
currentMeta.locked !== incomingMeta.locked ||
|
currentMeta.locked !== incomingMeta.locked ||
|
||||||
currentMeta.groupId !== incomingMeta.groupId;
|
currentMeta.groupId !== incomingMeta.groupId;
|
||||||
|
const styleChanged =
|
||||||
|
!styleEquals(props.style, normalized.style) ||
|
||||||
|
props.width !== normalized.width ||
|
||||||
|
props.height !== normalized.height;
|
||||||
|
|
||||||
if (needPersist) {
|
if (metaChanged || styleChanged) {
|
||||||
lfInstance.setProperties(model.id, { ...props, meta: incomingMeta });
|
lfInstance.setProperties(model.id, normalized);
|
||||||
}
|
}
|
||||||
applyMetaToModel(model, incomingMeta);
|
applyMetaToModel(model, normalized.meta);
|
||||||
|
applyStyleToModel(model, normalized.style);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAllNodes() {
|
function normalizeAllNodes() {
|
||||||
@@ -688,9 +708,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const model = lfInstance.getNodeModelById(nodeId);
|
const model = lfInstance.getNodeModelById(nodeId);
|
||||||
if (model && data.properties?.meta) {
|
if (model) normalizeNodeModel(model);
|
||||||
applyMetaToModel(model, data.properties.meta);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
lfInstance.on('selection:selected', () => updateSelectedCount());
|
lfInstance.on('selection:selected', () => updateSelectedCount());
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import YuhunPanel from './panels/YuhunPanel.vue';
|
|||||||
import PropertyRulePanel from './panels/PropertyRulePanel.vue';
|
import PropertyRulePanel from './panels/PropertyRulePanel.vue';
|
||||||
import ImagePanel from './panels/ImagePanel.vue';
|
import ImagePanel from './panels/ImagePanel.vue';
|
||||||
import TextPanel from './panels/TextPanel.vue';
|
import TextPanel from './panels/TextPanel.vue';
|
||||||
|
import StylePanel from './panels/StylePanel.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
height: {
|
height: {
|
||||||
@@ -59,6 +60,8 @@ const panelComponent = computed(() => panelMap[nodeType.value] || null);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StylePanel :node="selectedNode" />
|
||||||
|
|
||||||
<component v-if="panelComponent" :is="panelComponent" :node="selectedNode" />
|
<component v-if="panelComponent" :is="panelComponent" :node="selectedNode" />
|
||||||
<div v-else class="property-section">
|
<div v-else class="property-section">
|
||||||
<div class="section-header">暂无特定属性</div>
|
<div class="section-header">暂无特定属性</div>
|
||||||
|
|||||||
@@ -1,44 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { EventType } from '@logicflow/core';
|
import { useNodeAppearance } from '@/ts/useNodeAppearance';
|
||||||
|
|
||||||
type FitMode = 'contain' | 'cover' | 'fill';
|
type FitMode = 'contain' | 'cover' | 'fill';
|
||||||
|
|
||||||
const getNode = inject('getNode') as (() => any) | undefined;
|
|
||||||
const getGraph = inject('getGraph') as (() => any) | undefined;
|
|
||||||
|
|
||||||
const imageUrl = ref('');
|
const imageUrl = ref('');
|
||||||
const fit = ref<FitMode>('contain');
|
const fit = ref<FitMode>('contain');
|
||||||
|
|
||||||
const refreshFromProps = (props?: any, node?: any) => {
|
const { containerStyle } = useNodeAppearance({
|
||||||
const targetProps = props ?? node?.properties ?? {};
|
onPropsChange(props) {
|
||||||
imageUrl.value = targetProps.image?.url ?? targetProps.url ?? '';
|
imageUrl.value = props.image?.url ?? props.url ?? '';
|
||||||
fit.value = targetProps.image?.fit ?? targetProps.fit ?? 'contain';
|
fit.value = props.image?.fit ?? props.fit ?? 'contain';
|
||||||
};
|
}
|
||||||
|
|
||||||
let offChange: (() => void) | null = null;
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const node = getNode?.();
|
|
||||||
refreshFromProps(node?.properties, node);
|
|
||||||
|
|
||||||
const graph = getGraph?.();
|
|
||||||
const handler = (eventData: any) => {
|
|
||||||
if (node && eventData.id === node.id) {
|
|
||||||
refreshFromProps(eventData.properties, node);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, handler);
|
|
||||||
offChange = () => graph?.eventCenter.off(EventType.NODE_PROPERTIES_CHANGE, handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
offChange?.();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="image-node">
|
<div class="image-node" :style="containerStyle">
|
||||||
<div class="image-wrapper">
|
<div class="image-wrapper">
|
||||||
<img v-if="imageUrl" :src="imageUrl" alt="图片节点" :style="{ objectFit: fit }" draggable="false" />
|
<img v-if="imageUrl" :src="imageUrl" alt="图片节点" :style="{ objectFit: fit }" draggable="false" />
|
||||||
<div v-else class="placeholder">
|
<div v-else class="placeholder">
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, inject } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { EventType } from '@logicflow/core';
|
import { useNodeAppearance } from '@/ts/useNodeAppearance';
|
||||||
|
|
||||||
const currentProperty = ref({ type: '未选择', priority: '可选' });
|
const currentProperty = ref({ type: '未选择', priority: '可选' });
|
||||||
|
|
||||||
const getNode = inject('getNode') as (() => any) | undefined;
|
const { containerStyle, textStyle } = useNodeAppearance({
|
||||||
const getGraph = inject('getGraph') as (() => any) | undefined;
|
onPropsChange(props) {
|
||||||
|
if (props.property) {
|
||||||
|
currentProperty.value = props.property;
|
||||||
onMounted(() => {
|
|
||||||
const node = getNode?.();
|
|
||||||
const graph = getGraph?.();
|
|
||||||
|
|
||||||
if (node?.properties?.property) {
|
|
||||||
currentProperty.value = node.properties.property;
|
|
||||||
}
|
|
||||||
|
|
||||||
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData: any) => {
|
|
||||||
if (eventData.id === node.id && eventData.properties?.property) {
|
|
||||||
currentProperty.value = eventData.properties.property;
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 辅助函数
|
// 辅助函数
|
||||||
@@ -50,19 +39,19 @@ const getPriorityName = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="property-node" :class="[currentProperty.priority ? `priority-${currentProperty.priority}` : '']">
|
<div class="property-node" :class="[currentProperty.priority ? `priority-${currentProperty.priority}` : '']">
|
||||||
<div class="node-content">
|
<div class="node-content" :style="containerStyle">
|
||||||
<div class="node-header">
|
<div class="node-header">
|
||||||
<div class="node-title">属性要求</div>
|
<div class="node-title" :style="textStyle">属性要求</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="node-body">
|
<div class="node-body">
|
||||||
<div class="property-main">
|
<div class="property-main">
|
||||||
<div class="property-type">{{ getPropertyTypeName() }}</div>
|
<div class="property-type" :style="textStyle">{{ getPropertyTypeName() }}</div>
|
||||||
<div v-if="currentProperty.type !== '未选择'" class="property-value">{{ currentProperty.value }}</div>
|
<div v-if="currentProperty.type !== '未选择'" class="property-value" :style="textStyle">{{ currentProperty.value }}</div>
|
||||||
<div v-else class="property-placeholder">点击设置属性</div>
|
<div v-else class="property-placeholder" :style="textStyle">点击设置属性</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="property-details" v-if="currentProperty.type !== '未选择'">
|
<div class="property-details" v-if="currentProperty.type !== '未选择'">
|
||||||
<div class="property-priority">优先级: {{ getPriorityName() }}</div>
|
<div class="property-priority" :style="textStyle">优先级: {{ getPriorityName() }}</div>
|
||||||
<div class="property-description" v-if="currentProperty.description">
|
<div class="property-description" v-if="currentProperty.description" :style="textStyle">
|
||||||
{{ currentProperty.description }}
|
{{ currentProperty.description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,4 +146,4 @@ const getPriorityName = () => {
|
|||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,41 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, onMounted, inject, watch} from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { EventType } from '@logicflow/core';
|
import { toTextStyle } from '@/ts/nodeStyle';
|
||||||
|
import { useNodeAppearance } from '@/ts/useNodeAppearance';
|
||||||
|
|
||||||
const currentShikigami = ref({ name: '未选择式神', avatar: '', rarity: '' });
|
const currentShikigami = ref({ name: '未选择式神', avatar: '', rarity: '' });
|
||||||
|
|
||||||
const getNode = inject('getNode') as (() => any) | undefined;
|
const { containerStyle, textStyle } = useNodeAppearance({
|
||||||
const getGraph = inject('getGraph') as (() => any) | undefined;
|
onPropsChange(props) {
|
||||||
|
if (props.shikigami) {
|
||||||
|
currentShikigami.value = props.shikigami;
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const node = getNode?.();
|
|
||||||
const graph = getGraph?.();
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
if (node?.properties?.shikigami) {
|
|
||||||
currentShikigami.value = node.properties.shikigami;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听属性变化
|
|
||||||
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData: any) => {
|
|
||||||
if (eventData.id === node.id && eventData.properties?.shikigami) {
|
|
||||||
currentShikigami.value = eventData.properties.shikigami;
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mergedContainerStyle = computed(() => ({ ...containerStyle.value, boxSizing: 'border-box' }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="node-content" :style="mergedContainerStyle">
|
||||||
class="node-content"
|
|
||||||
:style="{
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: '8px'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
v-if="currentShikigami.avatar"
|
v-if="currentShikigami.avatar"
|
||||||
:src="currentShikigami.avatar"
|
:src="currentShikigami.avatar"
|
||||||
@@ -43,8 +25,8 @@ onMounted(() => {
|
|||||||
class="shikigami-image"
|
class="shikigami-image"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
<div v-else class="placeholder-text">点击选择式神</div>
|
<div v-else class="placeholder-text" :style="textStyle">点击选择式神</div>
|
||||||
<div class="name-text">{{ currentShikigami.name }}</div>
|
<div class="name-text" :style="textStyle">{{ currentShikigami.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -69,4 +51,4 @@ onMounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,32 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, inject } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { EventType } from '@logicflow/core';
|
import { useNodeAppearance } from '@/ts/useNodeAppearance';
|
||||||
|
|
||||||
const currentYuhun = ref({ name: '未选择御魂', avatar: '', type: '' });
|
const currentYuhun = ref({ name: '未选择御魂', avatar: '', type: '' });
|
||||||
|
|
||||||
const getNode = inject('getNode') as (() => any) | undefined;
|
const { containerStyle, textStyle } = useNodeAppearance({
|
||||||
const getGraph = inject('getGraph') as (() => any) | undefined;
|
onPropsChange(props) {
|
||||||
|
if (props.yuhun) {
|
||||||
|
currentYuhun.value = props.yuhun;
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const node = getNode?.();
|
|
||||||
const graph = getGraph?.();
|
|
||||||
|
|
||||||
if (node?.properties?.yuhun) {
|
|
||||||
currentYuhun.value = node.properties.yuhun;
|
|
||||||
}
|
|
||||||
|
|
||||||
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData: any) => {
|
|
||||||
if (eventData.id === node.id && eventData.properties?.yuhun) {
|
|
||||||
currentYuhun.value = eventData.properties.yuhun;
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="node-content">
|
<div class="node-content" :style="containerStyle">
|
||||||
<img
|
<img
|
||||||
v-if="currentYuhun.avatar"
|
v-if="currentYuhun.avatar"
|
||||||
:src="currentYuhun.avatar"
|
:src="currentYuhun.avatar"
|
||||||
@@ -34,9 +22,9 @@ onMounted(() => {
|
|||||||
class="yuhun-image"
|
class="yuhun-image"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
<div v-else class="placeholder-text">点击选择御魂</div>
|
<div v-else class="placeholder-text" :style="textStyle">点击选择御魂</div>
|
||||||
<div class="name-text">{{ currentYuhun.name }}</div>
|
<div class="name-text" :style="textStyle">{{ currentYuhun.name }}</div>
|
||||||
<div v-if="currentYuhun.type" class="type-text">{{ currentYuhun.type }}</div>
|
<div v-if="currentYuhun.type" class="type-text" :style="textStyle">{{ currentYuhun.type }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, watch } from 'vue';
|
import { reactive, watch } from 'vue';
|
||||||
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
import { normalizeNodeStyle } from '@/ts/nodeStyle';
|
||||||
|
|
||||||
type FitMode = 'contain' | 'cover' | 'fill';
|
type FitMode = 'contain' | 'cover' | 'fill';
|
||||||
|
|
||||||
@@ -29,12 +30,12 @@ const parseNumber = (value: any, fallback: number) => {
|
|||||||
|
|
||||||
const getImageProps = (node?: any): ImageForm => {
|
const getImageProps = (node?: any): ImageForm => {
|
||||||
const props = node?.properties ?? {};
|
const props = node?.properties ?? {};
|
||||||
const style = props.style ?? {};
|
const style = normalizeNodeStyle(props.style, { width: props.width ?? node?.width, height: props.height ?? node?.height });
|
||||||
return {
|
return {
|
||||||
url: props.image?.url ?? props.url ?? '',
|
url: props.image?.url ?? props.url ?? '',
|
||||||
fit: (props.image?.fit ?? props.fit ?? 'contain') as FitMode,
|
fit: (props.image?.fit ?? props.fit ?? 'contain') as FitMode,
|
||||||
width: parseNumber(props.width ?? style.width ?? node?.width, 180),
|
width: parseNumber(style.width, 180),
|
||||||
height: parseNumber(props.height ?? style.height ?? node?.height, 120)
|
height: parseNumber(style.height, 120)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,17 +65,18 @@ const applyImageChanges = (partial: Partial<ImageForm>) => {
|
|||||||
|
|
||||||
const baseProps = node.properties || {};
|
const baseProps = node.properties || {};
|
||||||
const merged = { ...getImageProps(node), ...partial };
|
const merged = { ...getImageProps(node), ...partial };
|
||||||
|
const currentStyle = normalizeNodeStyle(baseProps.style, { width: baseProps.width ?? node.width, height: baseProps.height ?? node.height });
|
||||||
|
const nextStyle = normalizeNodeStyle(
|
||||||
|
{ ...currentStyle, width: merged.width, height: merged.height },
|
||||||
|
{ width: merged.width, height: merged.height }
|
||||||
|
);
|
||||||
|
|
||||||
const nextProps = {
|
const nextProps = {
|
||||||
...baseProps,
|
...baseProps,
|
||||||
...merged,
|
...merged,
|
||||||
width: merged.width,
|
style: nextStyle,
|
||||||
height: merged.height,
|
width: nextStyle.width,
|
||||||
style: {
|
height: nextStyle.height,
|
||||||
...(baseProps.style || {}),
|
|
||||||
width: merged.width,
|
|
||||||
height: merged.height
|
|
||||||
},
|
|
||||||
image: {
|
image: {
|
||||||
...(baseProps.image || {}),
|
...(baseProps.image || {}),
|
||||||
url: merged.url,
|
url: merged.url,
|
||||||
|
|||||||
262
src/components/flow/panels/StylePanel.vue
Normal file
262
src/components/flow/panels/StylePanel.vue
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
import { normalizeNodeStyle, type NodeStyle } from '@/ts/nodeStyle';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
node: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type StyleForm = {
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
radius: number;
|
||||||
|
shadowColor: string;
|
||||||
|
shadowBlur: number;
|
||||||
|
shadowOffsetX: number;
|
||||||
|
shadowOffsetY: number;
|
||||||
|
opacity: number;
|
||||||
|
textAlign: 'left' | 'center' | 'right';
|
||||||
|
lineHeight: number;
|
||||||
|
fontWeight: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = reactive<StyleForm>({
|
||||||
|
fill: '#ffffff',
|
||||||
|
stroke: '#dcdfe6',
|
||||||
|
strokeWidth: 1,
|
||||||
|
radius: 4,
|
||||||
|
shadowColor: 'rgba(0,0,0,0.1)',
|
||||||
|
shadowBlur: 4,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowOffsetY: 2,
|
||||||
|
opacity: 1,
|
||||||
|
textAlign: 'left',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
fontWeight: 400
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncFromNode = (node?: any) => {
|
||||||
|
if (!node) return;
|
||||||
|
const baseProps = node.properties ?? {};
|
||||||
|
const style = normalizeNodeStyle(baseProps.style, { width: baseProps.width ?? node.width, height: baseProps.height ?? node.height });
|
||||||
|
form.fill = style.fill ?? form.fill;
|
||||||
|
form.stroke = style.stroke ?? form.stroke;
|
||||||
|
form.strokeWidth = style.strokeWidth ?? form.strokeWidth;
|
||||||
|
form.radius = typeof style.radius === 'number' ? style.radius : style.radius?.[0] ?? form.radius;
|
||||||
|
form.shadowColor = style.shadow?.color ?? form.shadowColor;
|
||||||
|
form.shadowBlur = style.shadow?.blur ?? form.shadowBlur;
|
||||||
|
form.shadowOffsetX = style.shadow?.offsetX ?? form.shadowOffsetX;
|
||||||
|
form.shadowOffsetY = style.shadow?.offsetY ?? form.shadowOffsetY;
|
||||||
|
form.opacity = style.opacity ?? form.opacity;
|
||||||
|
form.textAlign = (style.textStyle?.align as StyleForm['textAlign']) ?? form.textAlign;
|
||||||
|
form.lineHeight = style.textStyle?.lineHeight ?? form.lineHeight;
|
||||||
|
form.fontWeight = style.textStyle?.fontWeight ?? form.fontWeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.node,
|
||||||
|
(node) => {
|
||||||
|
if (node) {
|
||||||
|
syncFromNode(node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getShadowFromForm = () => ({
|
||||||
|
color: form.shadowColor,
|
||||||
|
blur: form.shadowBlur,
|
||||||
|
offsetX: form.shadowOffsetX,
|
||||||
|
offsetY: form.shadowOffsetY
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyStyle = (partial: Partial<NodeStyle>) => {
|
||||||
|
const lf = getLogicFlowInstance();
|
||||||
|
const node = props.node;
|
||||||
|
if (!lf || !node) return;
|
||||||
|
|
||||||
|
const baseProps = node.properties || {};
|
||||||
|
const currentStyle = normalizeNodeStyle(baseProps.style, { width: baseProps.width ?? node.width, height: baseProps.height ?? node.height });
|
||||||
|
const mergedStyleInput: Partial<NodeStyle> = {
|
||||||
|
...currentStyle,
|
||||||
|
...partial,
|
||||||
|
shadow: partial.shadow ? { ...currentStyle.shadow, ...partial.shadow } : currentStyle.shadow,
|
||||||
|
textStyle: partial.textStyle ? { ...currentStyle.textStyle, ...partial.textStyle } : currentStyle.textStyle
|
||||||
|
};
|
||||||
|
const nextStyle = normalizeNodeStyle(mergedStyleInput, { width: currentStyle.width, height: currentStyle.height });
|
||||||
|
|
||||||
|
lf.setProperties(node.id, {
|
||||||
|
...baseProps,
|
||||||
|
style: nextStyle,
|
||||||
|
width: nextStyle.width,
|
||||||
|
height: nextStyle.height
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyShadow = (override?: Partial<NodeStyle['shadow']>) => {
|
||||||
|
applyStyle({ shadow: { ...getShadowFromForm(), ...(override || {}) } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTextStyle = (override?: Partial<NodeStyle['textStyle']>) => {
|
||||||
|
applyStyle({
|
||||||
|
textStyle: {
|
||||||
|
...((props.node?.properties?.style?.textStyle as NodeStyle['textStyle']) || {}),
|
||||||
|
align: form.textAlign,
|
||||||
|
lineHeight: form.lineHeight,
|
||||||
|
fontWeight: form.fontWeight,
|
||||||
|
...(override || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="property-section">
|
||||||
|
<div class="section-header">样式</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">填充</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-color-picker v-model="form.fill" size="small" @change="(val: string) => applyStyle({ fill: val })" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">描边</div>
|
||||||
|
<div class="property-value row">
|
||||||
|
<el-color-picker v-model="form.stroke" size="small" @change="(val: string) => applyStyle({ stroke: val })" />
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.strokeWidth"
|
||||||
|
:min="0"
|
||||||
|
:max="20"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="(val) => applyStyle({ stroke: form.stroke, strokeWidth: Number(val) || 0 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">圆角</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.radius"
|
||||||
|
:min="0"
|
||||||
|
:max="200"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="(val) => applyStyle({ radius: Number(val) || 0 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">阴影</div>
|
||||||
|
<div class="property-value shadow-grid">
|
||||||
|
<el-color-picker v-model="form.shadowColor" size="small" @change="(val: string) => applyShadow({ color: val })" />
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.shadowBlur"
|
||||||
|
:min="0"
|
||||||
|
:max="50"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="(val) => applyShadow({ blur: Number(val) || 0 })"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.shadowOffsetX"
|
||||||
|
:min="-100"
|
||||||
|
:max="100"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="(val) => applyShadow({ offsetX: Number(val) || 0 })"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.shadowOffsetY"
|
||||||
|
:min="-100"
|
||||||
|
:max="100"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="(val) => applyShadow({ offsetY: Number(val) || 0 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">透明度</div>
|
||||||
|
<div class="property-value slider-row">
|
||||||
|
<el-slider
|
||||||
|
v-model="form.opacity"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.05"
|
||||||
|
show-input
|
||||||
|
:show-input-controls="false"
|
||||||
|
@change="(val) => applyStyle({ opacity: Number(val) || 0 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">文字对齐</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-select
|
||||||
|
v-model="form.textAlign"
|
||||||
|
size="small"
|
||||||
|
style="width: 100%;"
|
||||||
|
@change="(val) => applyTextStyle({ align: val as StyleForm['textAlign'] })"
|
||||||
|
>
|
||||||
|
<el-option label="左对齐" value="left" />
|
||||||
|
<el-option label="居中" value="center" />
|
||||||
|
<el-option label="右对齐" value="right" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">行高 / 字重</div>
|
||||||
|
<div class="property-value row">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.lineHeight"
|
||||||
|
:min="0.8"
|
||||||
|
:max="3"
|
||||||
|
:step="0.1"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="(val) => applyTextStyle({ lineHeight: Number(val) || 1 })"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="form.fontWeight"
|
||||||
|
size="small"
|
||||||
|
style="flex: 1;"
|
||||||
|
@change="(val) => applyTextStyle({ fontWeight: val as number | string })"
|
||||||
|
>
|
||||||
|
<el-option label="细(300)" :value="300" />
|
||||||
|
<el-option label="常规(400)" :value="400" />
|
||||||
|
<el-option label="中等(500)" :value="500" />
|
||||||
|
<el-option label="半粗(600)" :value="600" />
|
||||||
|
<el-option label="粗体(700)" :value="700" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-row {
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
139
src/ts/nodeStyle.ts
Normal file
139
src/ts/nodeStyle.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import type { CSSProperties } from 'vue';
|
||||||
|
import { DefaultNodeStyle, type NodeStyle } from './schema';
|
||||||
|
|
||||||
|
const toNumber = (value: any, fallback: number | undefined): number | undefined => {
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isFinite(num)) return num;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
const normalizeRadius = (radius: NodeStyle['radius']): NodeStyle['radius'] => {
|
||||||
|
const defaultRadius = DefaultNodeStyle.radius ?? 0;
|
||||||
|
if (Array.isArray(radius)) {
|
||||||
|
const fallback = Array.isArray(DefaultNodeStyle.radius)
|
||||||
|
? DefaultNodeStyle.radius
|
||||||
|
: [defaultRadius, defaultRadius, defaultRadius, defaultRadius];
|
||||||
|
return [
|
||||||
|
toNumber(radius[0], fallback[0]) ?? 0,
|
||||||
|
toNumber(radius[1], fallback[1]) ?? 0,
|
||||||
|
toNumber(radius[2], fallback[2]) ?? 0,
|
||||||
|
toNumber(radius[3], fallback[3]) ?? 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return toNumber(radius, defaultRadius) ?? defaultRadius;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeNodeStyle(
|
||||||
|
style?: Partial<NodeStyle>,
|
||||||
|
sizeFallback?: { width?: number; height?: number }
|
||||||
|
): NodeStyle {
|
||||||
|
const incoming = style ?? {};
|
||||||
|
const width = toNumber(incoming.width, sizeFallback?.width ?? DefaultNodeStyle.width) ?? DefaultNodeStyle.width;
|
||||||
|
const height =
|
||||||
|
toNumber(incoming.height, sizeFallback?.height ?? DefaultNodeStyle.height) ?? DefaultNodeStyle.height;
|
||||||
|
|
||||||
|
const shadow = incoming.shadow ?? {};
|
||||||
|
const textStyle = incoming.textStyle;
|
||||||
|
const hasTextStyle = textStyle != null;
|
||||||
|
|
||||||
|
const resolvedOpacity = incoming.opacity != null
|
||||||
|
? clamp(toNumber(incoming.opacity, DefaultNodeStyle.opacity ?? 1) ?? 1, 0, 1)
|
||||||
|
: DefaultNodeStyle.opacity ?? 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DefaultNodeStyle,
|
||||||
|
...incoming,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
opacity: resolvedOpacity,
|
||||||
|
radius: normalizeRadius(incoming.radius),
|
||||||
|
strokeWidth: toNumber(incoming.strokeWidth, DefaultNodeStyle.strokeWidth) ?? DefaultNodeStyle.strokeWidth,
|
||||||
|
shadow: {
|
||||||
|
...DefaultNodeStyle.shadow,
|
||||||
|
...shadow,
|
||||||
|
blur: toNumber(shadow.blur, DefaultNodeStyle.shadow?.blur) ?? DefaultNodeStyle.shadow?.blur,
|
||||||
|
offsetX: toNumber(shadow.offsetX, DefaultNodeStyle.shadow?.offsetX) ?? DefaultNodeStyle.shadow?.offsetX,
|
||||||
|
offsetY: toNumber(shadow.offsetY, DefaultNodeStyle.shadow?.offsetY) ?? DefaultNodeStyle.shadow?.offsetY
|
||||||
|
},
|
||||||
|
textStyle: hasTextStyle
|
||||||
|
? {
|
||||||
|
color: textStyle?.color,
|
||||||
|
fontFamily: textStyle?.fontFamily,
|
||||||
|
fontSize: textStyle?.fontSize != null
|
||||||
|
? toNumber(textStyle.fontSize, DefaultNodeStyle.textStyle?.fontSize)
|
||||||
|
: undefined,
|
||||||
|
fontWeight: textStyle?.fontWeight,
|
||||||
|
lineHeight: textStyle?.lineHeight,
|
||||||
|
align: textStyle?.align,
|
||||||
|
verticalAlign: textStyle?.verticalAlign,
|
||||||
|
letterSpacing: textStyle?.letterSpacing,
|
||||||
|
padding: textStyle?.padding,
|
||||||
|
background: textStyle?.background
|
||||||
|
}
|
||||||
|
: textStyle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePropertiesWithStyle<T extends Record<string, any>>(
|
||||||
|
props: T,
|
||||||
|
sizeFallback?: { width?: number; height?: number }
|
||||||
|
): T & { style: NodeStyle } {
|
||||||
|
const normalizedStyle = normalizeNodeStyle(props?.style, sizeFallback);
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
style: normalizedStyle,
|
||||||
|
width: normalizedStyle.width,
|
||||||
|
height: normalizedStyle.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function styleEquals(a?: Partial<NodeStyle>, b?: Partial<NodeStyle>): boolean {
|
||||||
|
return JSON.stringify(normalizeNodeStyle(a)) === JSON.stringify(normalizeNodeStyle(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function radiusToCss(radius?: NodeStyle['radius']): string | undefined {
|
||||||
|
if (radius == null) return undefined;
|
||||||
|
if (Array.isArray(radius)) {
|
||||||
|
return radius.map((r) => `${r}px`).join(' ');
|
||||||
|
}
|
||||||
|
return `${radius}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBoxShadow(shadow?: NodeStyle['shadow']): string | undefined {
|
||||||
|
if (!shadow || shadow.color == null) return undefined;
|
||||||
|
const blur = toNumber(shadow.blur, 0) ?? 0;
|
||||||
|
const offsetX = toNumber(shadow.offsetX, 0) ?? 0;
|
||||||
|
const offsetY = toNumber(shadow.offsetY, 0) ?? 0;
|
||||||
|
return `${offsetX}px ${offsetY}px ${blur}px ${shadow.color}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toContainerStyle(style: NodeStyle): CSSProperties {
|
||||||
|
return {
|
||||||
|
background: style.fill,
|
||||||
|
borderColor: style.stroke,
|
||||||
|
borderWidth: style.stroke ? `${style.strokeWidth ?? 1}px` : undefined,
|
||||||
|
borderStyle: style.stroke ? 'solid' : undefined,
|
||||||
|
borderRadius: radiusToCss(style.radius),
|
||||||
|
boxShadow: buildBoxShadow(style.shadow),
|
||||||
|
opacity: style.opacity ?? 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTextStyle(style: NodeStyle): CSSProperties {
|
||||||
|
const text = style.textStyle ?? {};
|
||||||
|
return {
|
||||||
|
color: text.color,
|
||||||
|
fontFamily: text.fontFamily,
|
||||||
|
fontSize: text.fontSize != null ? `${text.fontSize}px` : undefined,
|
||||||
|
fontWeight: text.fontWeight,
|
||||||
|
lineHeight: text.lineHeight as CSSProperties['lineHeight'],
|
||||||
|
textAlign: text.align
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractStyleFromNode(node?: any): NodeStyle {
|
||||||
|
const props = node?.properties ?? {};
|
||||||
|
return normalizeNodeStyle(props.style, { width: props.width ?? node?.width, height: props.height ?? node?.height });
|
||||||
|
}
|
||||||
52
src/ts/useNodeAppearance.ts
Normal file
52
src/ts/useNodeAppearance.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { EventType } from '@logicflow/core';
|
||||||
|
import { normalizeNodeStyle, toContainerStyle, toTextStyle, type NodeStyle } from './nodeStyle';
|
||||||
|
|
||||||
|
type PropsChangeHandler = (props: any, node: any) => void;
|
||||||
|
|
||||||
|
export function useNodeAppearance(options?: { onPropsChange?: PropsChangeHandler }) {
|
||||||
|
const getNode = inject('getNode') as (() => any) | undefined;
|
||||||
|
const getGraph = inject('getGraph') as (() => any) | undefined;
|
||||||
|
|
||||||
|
const style = ref<NodeStyle>(normalizeNodeStyle());
|
||||||
|
|
||||||
|
const syncFromProps = (props?: any, node?: any) => {
|
||||||
|
const target = props ?? node?.properties ?? {};
|
||||||
|
style.value = normalizeNodeStyle(target.style, {
|
||||||
|
width: target.width ?? node?.width,
|
||||||
|
height: target.height ?? node?.height
|
||||||
|
});
|
||||||
|
options?.onPropsChange?.(target, node);
|
||||||
|
};
|
||||||
|
|
||||||
|
let offChange: (() => void) | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const node = getNode?.();
|
||||||
|
const graph = getGraph?.();
|
||||||
|
|
||||||
|
syncFromProps(node?.properties, node);
|
||||||
|
|
||||||
|
const handler = (eventData: any) => {
|
||||||
|
if (eventData.id === node?.id) {
|
||||||
|
syncFromProps(eventData.properties, node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, handler);
|
||||||
|
offChange = () => graph?.eventCenter.off(EventType.NODE_PROPERTIES_CHANGE, handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
offChange?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerStyle = computed(() => toContainerStyle(style.value));
|
||||||
|
const textStyle = computed(() => toTextStyle(style.value));
|
||||||
|
|
||||||
|
return {
|
||||||
|
style,
|
||||||
|
containerStyle,
|
||||||
|
textStyle,
|
||||||
|
syncFromProps
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user