mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 06:55:26 +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%
|
||||
- 已完成:
|
||||
@@ -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/Snapshot;Toolbar 增加吸附/对齐开关与清空画布。
|
||||
8) 矢量节点 MVP:`vectorNode`(SVG path/rect/ellipse/polygon),属性面板支持 path/stroke/fill/strokeWidth;新增 SVG 导入弹窗。
|
||||
9) 资源与导出增强:图片资源选择/上传弹窗(base64 或预留 `assetId`),导出 SVG/PDF(PDF 可延后)。
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
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