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

@@ -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>