feat: unify node style schema and add full style editing panel

This commit is contained in:
2025-12-28 14:58:31 +08:00
parent 6f70269322
commit c65c880ad8
11 changed files with 546 additions and 132 deletions

View File

@@ -1,6 +1,6 @@
# 模块状态总览(重写)
总体完成度(粗略):约 73%
总体完成度(粗略):约 75%
## 1. 画布LogicFlow — 完成度75%
- 已完成:
@@ -24,14 +24,15 @@
- 点击快速创建、组件预览缩略图、搜索与分组折叠
- 外置配置JSON与动态加载便于扩展
## 3. 右侧属性面板Inspector — 完成度:70%
## 3. 右侧属性面板Inspector — 完成度:80%
- 已完成:
- 按节点类型切换 UI显示基本信息ID/类型src/components/flow/PropertyPanel.vue面板按节点类型拆分子组件
- 打开式神/御魂/属性弹窗,并通过 `lf.setProperties` 回写到节点
- `imageNode` 属性编辑URL/本地上传、fit、宽高与预览写回 `properties` 同步渲染
- 样式模型:统一 `properties.style`,属性面板支持填充/描边/圆角/阴影/透明度/文字对齐/行高/字重,节点渲染消费样式
- 未完成:
- `textNode` 富文本编辑与同步
- 字段校验/联动、常用模板一键填充、更多样式项(填充/描边/圆角/阴影/透明度)
- 字段校验/联动、常用模板一键填充
## 4. 工具栏Toolbar — 完成度80%
- 已完成:
@@ -107,7 +108,7 @@
3) 图层命令 MVP基于 LogicFlow 的层级/前后置 API 封装 bringToFront/sendToBack/bringForward/sendBackward + 右键菜单,如需持久化仅同步引擎提供的层级信息(`src/components/flow/FlowEditor.vue`)。已完成:置顶/置底 + 右键菜单;待补:单步前移/后移。
4) 多选/对齐/吸附:框选、对齐线、吸附网格;左/右/上/下/水平/垂直居中与横/纵等距分布FlowEditor/extension。已完成
5) 快捷键与微调Del 删除、方向键微移、Ctrl+C/V 复制粘贴、Ctrl+G/U 组/解组(简单组:父 meta id + 同步移动)、锁定/隐藏(`properties.locked`/`visible`)。
6) 样式模型补齐:统一 `properties.style` 字段并在 PropertyPanel 全量编辑(填充/描边/圆角/阴影/透明度/文字对齐/行高/字重)。
6) 样式模型补齐:统一 `properties.style` 字段并在 PropertyPanel 全量编辑(填充/描边/圆角/阴影/透明度/文字对齐/行高/字重)。【已完成】
7) 扩展与控制:接入 MiniMap/Control/SnapshotToolbar 增加吸附/对齐开关与清空画布。
8) 矢量节点 MVP`vectorNode`SVG path/rect/ellipse/polygon属性面板支持 path/stroke/fill/strokeWidth新增 SVG 导入弹窗。
9) 资源与导出增强:图片资源选择/上传弹窗base64 或预留 `assetId`),导出 SVG/PDFPDF 可延后)。

View File

@@ -74,6 +74,7 @@ import ImageNode from './nodes/common/ImageNode.vue';
import PropertyPanel from './PropertyPanel.vue';
import { useGlobalMessage } from '@/ts/useGlobalMessage';
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@/ts/nodeStyle';
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
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) {
const lfInstance = lf.value;
if (!lfInstance) return;
const props = (model.getProperties?.() as any) ?? (model as any)?.properties ?? {};
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 needPersist =
const metaChanged =
currentMeta.visible !== incomingMeta.visible ||
currentMeta.locked !== incomingMeta.locked ||
currentMeta.groupId !== incomingMeta.groupId;
const styleChanged =
!styleEquals(props.style, normalized.style) ||
props.width !== normalized.width ||
props.height !== normalized.height;
if (needPersist) {
lfInstance.setProperties(model.id, { ...props, meta: incomingMeta });
if (metaChanged || styleChanged) {
lfInstance.setProperties(model.id, normalized);
}
applyMetaToModel(model, incomingMeta);
applyMetaToModel(model, normalized.meta);
applyStyleToModel(model, normalized.style);
}
function normalizeAllNodes() {
@@ -688,9 +708,7 @@ onMounted(() => {
}
}
const model = lfInstance.getNodeModelById(nodeId);
if (model && data.properties?.meta) {
applyMetaToModel(model, data.properties.meta);
}
if (model) normalizeNodeModel(model);
});
lfInstance.on('selection:selected', () => updateSelectedCount());

View File

@@ -5,6 +5,7 @@ import YuhunPanel from './panels/YuhunPanel.vue';
import PropertyRulePanel from './panels/PropertyRulePanel.vue';
import ImagePanel from './panels/ImagePanel.vue';
import TextPanel from './panels/TextPanel.vue';
import StylePanel from './panels/StylePanel.vue';
const props = defineProps({
height: {
@@ -59,6 +60,8 @@ const panelComponent = computed(() => panelMap[nodeType.value] || null);
</div>
</div>
<StylePanel :node="selectedNode" />
<component v-if="panelComponent" :is="panelComponent" :node="selectedNode" />
<div v-else class="property-section">
<div class="section-header">暂无特定属性</div>

View File

@@ -1,44 +1,22 @@
<script setup lang="ts">
import { inject, onBeforeUnmount, onMounted, ref } from 'vue';
import { EventType } from '@logicflow/core';
import { ref } from 'vue';
import { useNodeAppearance } from '@/ts/useNodeAppearance';
type FitMode = 'contain' | 'cover' | 'fill';
const getNode = inject('getNode') as (() => any) | undefined;
const getGraph = inject('getGraph') as (() => any) | undefined;
const imageUrl = ref('');
const fit = ref<FitMode>('contain');
const refreshFromProps = (props?: any, node?: any) => {
const targetProps = props ?? node?.properties ?? {};
imageUrl.value = targetProps.image?.url ?? targetProps.url ?? '';
fit.value = targetProps.image?.fit ?? targetProps.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?.();
const { containerStyle } = useNodeAppearance({
onPropsChange(props) {
imageUrl.value = props.image?.url ?? props.url ?? '';
fit.value = props.image?.fit ?? props.fit ?? 'contain';
}
});
</script>
<template>
<div class="image-node">
<div class="image-node" :style="containerStyle">
<div class="image-wrapper">
<img v-if="imageUrl" :src="imageUrl" alt="图片节点" :style="{ objectFit: fit }" draggable="false" />
<div v-else class="placeholder">

View File

@@ -1,26 +1,15 @@
<script setup lang="ts">
import { ref, watch, onMounted, inject } from 'vue';
import { EventType } from '@logicflow/core';
import { ref } from 'vue';
import { useNodeAppearance } from '@/ts/useNodeAppearance';
const currentProperty = ref({ type: '未选择', priority: '可选' });
const getNode = inject('getNode') as (() => any) | undefined;
const getGraph = inject('getGraph') as (() => any) | undefined;
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;
const { containerStyle, textStyle } = useNodeAppearance({
onPropsChange(props) {
if (props.property) {
currentProperty.value = props.property;
}
});
}
});
// 辅助函数
@@ -50,19 +39,19 @@ const getPriorityName = () => {
<template>
<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-title">属性要求</div>
<div class="node-title" :style="textStyle">属性要求</div>
</div>
<div class="node-body">
<div class="property-main">
<div class="property-type">{{ getPropertyTypeName() }}</div>
<div v-if="currentProperty.type !== '未选择'" class="property-value">{{ currentProperty.value }}</div>
<div v-else class="property-placeholder">点击设置属性</div>
<div class="property-type" :style="textStyle">{{ getPropertyTypeName() }}</div>
<div v-if="currentProperty.type !== '未选择'" class="property-value" :style="textStyle">{{ currentProperty.value }}</div>
<div v-else class="property-placeholder" :style="textStyle">点击设置属性</div>
</div>
<div class="property-details" v-if="currentProperty.type !== '未选择'">
<div class="property-priority">优先级: {{ getPriorityName() }}</div>
<div class="property-description" v-if="currentProperty.description">
<div class="property-priority" :style="textStyle">优先级: {{ getPriorityName() }}</div>
<div class="property-description" v-if="currentProperty.description" :style="textStyle">
{{ currentProperty.description }}
</div>
</div>
@@ -157,4 +146,4 @@ const getPriorityName = () => {
padding-top: 5px;
word-break: break-all;
}
</style>
</style>

View File

@@ -1,41 +1,23 @@
<script setup lang="ts">
import {ref, onMounted, inject, watch} from 'vue';
import { EventType } from '@logicflow/core';
import { computed, ref } from 'vue';
import { toTextStyle } from '@/ts/nodeStyle';
import { useNodeAppearance } from '@/ts/useNodeAppearance';
const currentShikigami = ref({ name: '未选择式神', avatar: '', rarity: '' });
const getNode = inject('getNode') as (() => any) | undefined;
const getGraph = inject('getGraph') as (() => any) | undefined;
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 { containerStyle, textStyle } = useNodeAppearance({
onPropsChange(props) {
if (props.shikigami) {
currentShikigami.value = props.shikigami;
}
});
}
});
const mergedContainerStyle = computed(() => ({ ...containerStyle.value, boxSizing: 'border-box' }));
</script>
<template>
<div
class="node-content"
:style="{
boxSizing: 'border-box',
background: '#fff',
borderRadius: '8px'
}"
>
<div class="node-content" :style="mergedContainerStyle">
<img
v-if="currentShikigami.avatar"
:src="currentShikigami.avatar"
@@ -43,8 +25,8 @@ onMounted(() => {
class="shikigami-image"
draggable="false"
/>
<div v-else class="placeholder-text">点击选择式神</div>
<div class="name-text">{{ currentShikigami.name }}</div>
<div v-else class="placeholder-text" :style="textStyle">点击选择式神</div>
<div class="name-text" :style="textStyle">{{ currentShikigami.name }}</div>
</div>
</template>
@@ -69,4 +51,4 @@ onMounted(() => {
text-align: center;
margin-top: 8px;
}
</style>
</style>

View File

@@ -1,32 +1,20 @@
<script setup lang="ts">
import { ref, watch, onMounted, inject } from 'vue';
import { EventType } from '@logicflow/core';
import { ref } from 'vue';
import { useNodeAppearance } from '@/ts/useNodeAppearance';
const currentYuhun = ref({ name: '未选择御魂', avatar: '', type: '' });
const getNode = inject('getNode') as (() => any) | undefined;
const getGraph = inject('getGraph') as (() => any) | undefined;
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;
const { containerStyle, textStyle } = useNodeAppearance({
onPropsChange(props) {
if (props.yuhun) {
currentYuhun.value = props.yuhun;
}
});
}
});
</script>
<template>
<div class="node-content">
<div class="node-content" :style="containerStyle">
<img
v-if="currentYuhun.avatar"
:src="currentYuhun.avatar"
@@ -34,9 +22,9 @@ onMounted(() => {
class="yuhun-image"
draggable="false"
/>
<div v-else class="placeholder-text">点击选择御魂</div>
<div class="name-text">{{ currentYuhun.name }}</div>
<div v-if="currentYuhun.type" class="type-text">{{ currentYuhun.type }}</div>
<div v-else class="placeholder-text" :style="textStyle">点击选择御魂</div>
<div class="name-text" :style="textStyle">{{ currentYuhun.name }}</div>
<div v-if="currentYuhun.type" class="type-text" :style="textStyle">{{ currentYuhun.type }}</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
import { normalizeNodeStyle } from '@/ts/nodeStyle';
type FitMode = 'contain' | 'cover' | 'fill';
@@ -29,12 +30,12 @@ const parseNumber = (value: any, fallback: number) => {
const getImageProps = (node?: any): ImageForm => {
const props = node?.properties ?? {};
const style = props.style ?? {};
const style = normalizeNodeStyle(props.style, { width: props.width ?? node?.width, height: props.height ?? node?.height });
return {
url: props.image?.url ?? props.url ?? '',
fit: (props.image?.fit ?? props.fit ?? 'contain') as FitMode,
width: parseNumber(props.width ?? style.width ?? node?.width, 180),
height: parseNumber(props.height ?? style.height ?? node?.height, 120)
width: parseNumber(style.width, 180),
height: parseNumber(style.height, 120)
};
};
@@ -64,17 +65,18 @@ const applyImageChanges = (partial: Partial<ImageForm>) => {
const baseProps = node.properties || {};
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 = {
...baseProps,
...merged,
width: merged.width,
height: merged.height,
style: {
...(baseProps.style || {}),
width: merged.width,
height: merged.height
},
style: nextStyle,
width: nextStyle.width,
height: nextStyle.height,
image: {
...(baseProps.image || {}),
url: merged.url,

View 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
View 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 });
}

View 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
};
}