feat(flow): migrate text node to quill rendering and transparent default style

This commit is contained in:
2026-02-24 20:10:30 +08:00
parent e1f9a0453c
commit 5e665966db
4 changed files with 244 additions and 134 deletions

View File

@@ -47,7 +47,10 @@ const componentGroups = [
type: 'textNode',
description: '可编辑文本的节点',
data: {
text: '双击编辑文字',
text: {
content: '<p>请输入文本</p>',
rich: true
},
width: 200,
height: 120
}
@@ -279,4 +282,4 @@ const handleMouseDown = (e, component) => {
margin-bottom: 6px;
color: #333;
}
</style>
</style>

View File

@@ -1,30 +1,95 @@
<script setup lang="ts">
// LogicFlow 会自动处理文本节点的渲染和编辑
import { computed, ref } from 'vue';
import { useNodeAppearance } from '@/ts/useNodeAppearance';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
const DEFAULT_HTML = '<p>请输入文本</p>';
const richHtml = ref(DEFAULT_HTML);
const escapeHtml = (value: string) =>
value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const normalizeTextHtml = (rawText: any): string => {
if (typeof rawText === 'string') {
const trimmed = rawText.trim();
if (!trimmed) return DEFAULT_HTML;
return trimmed.startsWith('<') ? trimmed : `<p>${escapeHtml(rawText)}</p>`;
}
if (rawText && typeof rawText === 'object') {
const content = typeof rawText.content === 'string' ? rawText.content : '';
if (!content.trim()) return DEFAULT_HTML;
return rawText.rich === false ? `<p>${escapeHtml(content)}</p>` : content;
}
return DEFAULT_HTML;
};
const { containerStyle, textStyle } = useNodeAppearance({
onPropsChange(props) {
richHtml.value = normalizeTextHtml(props?.text);
}
});
const DEFAULT_BG = '#ffffff';
const DEFAULT_BORDER = '#dcdfe6';
const DEFAULT_SHADOW = '0px 2px 4px rgba(0,0,0,0.1)';
const displayContainerStyle = computed(() => {
const style: Record<string, any> = { ...(containerStyle.value || {}) };
const background = String(style.background ?? '').toLowerCase();
const borderColor = String(style.borderColor ?? '').toLowerCase();
const boxShadow = String(style.boxShadow ?? '').replaceAll(/\s+/g, '');
// Keep user-customized style, but make legacy/default text-node chrome transparent.
if (!background || background === DEFAULT_BG || background === 'rgb(255,255,255)') {
style.background = 'transparent';
}
if (!borderColor || borderColor === DEFAULT_BORDER || borderColor === 'rgb(220,223,230)') {
style.borderColor = 'transparent';
}
if (!boxShadow || boxShadow === DEFAULT_SHADOW.replaceAll(/\s+/g, '')) {
style.boxShadow = 'none';
}
return style;
});
</script>
<template>
<div class="text-content">
<!-- LogicFlow 会自动渲染 text 内容 -->
<div class="text-node" :style="displayContainerStyle">
<div class="text-content ql-editor" :style="textStyle" v-html="richHtml"></div>
</div>
</template>
<style scoped>
.text-node {
width: 100%;
height: 100%;
overflow: hidden;
}
.text-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
outline: none;
word-break: break-word;
padding: 4px;
box-sizing: border-box;
cursor: text;
padding: 8px;
overflow: auto;
word-break: break-word;
pointer-events: none;
}
.text-content:focus {
background: rgba(64, 158, 255, 0.1);
border-radius: 2px;
.text-content :deep(p) {
margin: 0 0 0.5em;
}
.text-content :deep(p:last-child) {
margin-bottom: 0;
}
</style>

View File

@@ -1,131 +1,74 @@
import { HtmlNodeModel } from '@logicflow/core';
const DEFAULT_TEXT_HTML = '<p>请输入文本</p>';
const DEFAULT_TEXT_STYLE = {
fill: 'transparent',
stroke: '',
shadow: {
color: 'transparent',
blur: 0,
offsetX: 0,
offsetY: 0
}
};
const parseSize = (value: unknown, fallback: number) => {
const next = Number(value);
return Number.isFinite(next) && next > 0 ? next : fallback;
};
const normalizeTextProperty = (rawText: any) => {
if (typeof rawText === 'string') {
return {
content: rawText,
rich: rawText.trim().startsWith('<')
};
}
if (rawText && typeof rawText === 'object') {
const content = typeof rawText.content === 'string' ? rawText.content : DEFAULT_TEXT_HTML;
const rich = rawText.rich == null ? content.trim().startsWith('<') : rawText.rich !== false;
return {
...rawText,
content,
rich
};
}
return {
content: DEFAULT_TEXT_HTML,
rich: true
};
};
class TextNodeModel extends HtmlNodeModel {
initNodeData(data: any) {
super.initNodeData(data);
// 从 data 中读取宽高,支持调整大小后的持久化
if (data.properties?.width) {
this.width = data.properties.width;
} else {
this.width = 200;
}
this.width = parseSize(data?.properties?.width, 200);
this.height = parseSize(data?.properties?.height, 120);
if (data.properties?.height) {
this.height = data.properties.height;
} else {
this.height = 120;
}
this.setProperty('width', this.width);
this.setProperty('height', this.height);
this.setProperty('text', normalizeTextProperty(data?.properties?.text));
// 计算 Label 宽度
const labelWidth = this.width - 20;
// 初始化或更新 Label 配置
if (data.properties?._label) {
// 如果已有 _label 配置,更新其宽度和坐标
// 处理数组情况(兼容旧数据)
let currentLabel = data.properties._label;
if (Array.isArray(currentLabel)) {
currentLabel = currentLabel[0] || {};
}
this.setProperty('_label', {
value: currentLabel.value || '双击编辑文本',
content: currentLabel.content || currentLabel.value || '双击编辑文本',
x: data.x,
y: data.y,
labelWidth: labelWidth,
textOverflowMode: 'wrap',
editable: true,
draggable: false,
});
} else if (data.properties?.text) {
// 如果有 text 属性但没有 _label创建 _label
this.setProperty('_label', {
value: data.properties.text,
content: data.properties.text,
x: data.x,
y: data.y,
labelWidth: labelWidth,
textOverflowMode: 'wrap',
editable: true,
draggable: false,
});
} else {
// 如果都没有,初始化一个默认的 label
this.setProperty('_label', {
value: '双击编辑文本',
content: '双击编辑文本',
x: data.x,
y: data.y,
labelWidth: labelWidth,
textOverflowMode: 'wrap',
editable: true,
draggable: false,
});
const hasStyle = Object.prototype.hasOwnProperty.call(data?.properties || {}, 'style');
if (!hasStyle) {
this.setProperty('style', DEFAULT_TEXT_STYLE);
}
}
setAttributes() {
// 设置默认尺寸(如果 initNodeData 中没有设置)
if (!this.width) {
this.width = 200;
}
if (!this.height) {
this.height = 120;
}
if (!this.width) this.width = 200;
if (!this.height) this.height = 120;
}
// 监听节点大小变化,更新 Label 宽度
resize(deltaX: number, deltaY: number) {
const result = super.resize?.(deltaX, deltaY);
// 持久化宽高到 properties
this.setProperty('width', this.width);
this.setProperty('height', this.height);
// 更新 Label 宽度和坐标
let currentLabel = this.properties._label || {};
if (Array.isArray(currentLabel)) {
currentLabel = currentLabel[0] || {};
}
this.setProperty('_label', {
value: currentLabel.value || '双击编辑文本',
content: currentLabel.content || currentLabel.value || '双击编辑文本',
x: this.x,
y: this.y,
labelWidth: this.width - 20,
textOverflowMode: 'wrap',
editable: true,
draggable: false,
});
return result;
}
// 当文本被编辑后,同步到 properties
updateText(value: string) {
super.updateText(value);
this.setProperty('text', value);
// 同时更新 _label 中的 value
let currentLabel = this.properties._label || {};
if (Array.isArray(currentLabel)) {
currentLabel = currentLabel[0] || {};
}
this.setProperty('_label', {
value: value,
content: value,
x: this.x,
y: this.y,
labelWidth: this.width - 20,
textOverflowMode: 'wrap',
editable: true,
draggable: false,
});
}
}
export default TextNodeModel;

View File

@@ -1,22 +1,121 @@
<script setup lang="ts">
const props = defineProps<{
node: any;
}>();
import { ref, watch } from 'vue';
import { QuillEditor } from '@vueup/vue-quill';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
const props = defineProps<{ node: any }>();
const DEFAULT_HTML = '<p>请输入文本</p>';
const editorHtml = ref(DEFAULT_HTML);
const toolbarOptions = [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['clean']
] as const;
const escapeHtml = (value: string) =>
value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const normalizeTextHtml = (rawText: any): string => {
if (typeof rawText === 'string') {
const trimmed = rawText.trim();
if (!trimmed) return DEFAULT_HTML;
return trimmed.startsWith('<') ? trimmed : `<p>${escapeHtml(rawText)}</p>`;
}
if (rawText && typeof rawText === 'object') {
const content = typeof rawText.content === 'string' ? rawText.content : '';
if (!content.trim()) return DEFAULT_HTML;
return rawText.rich === false ? `<p>${escapeHtml(content)}</p>` : content;
}
return DEFAULT_HTML;
};
const syncEditorFromNode = (node?: any) => {
const next = normalizeTextHtml(node?.properties?.text);
if (next !== editorHtml.value) {
editorHtml.value = next;
}
};
watch(
() => props.node,
(node) => {
syncEditorFromNode(node);
},
{ immediate: true, deep: true }
);
const handleContentChange = (value: string) => {
const lf = getLogicFlowInstance();
const node = props.node;
if (!lf || !node) return;
const nextHtml = value?.trim() ? value : DEFAULT_HTML;
editorHtml.value = nextHtml;
const current = normalizeTextHtml(node?.properties?.text);
if (current === nextHtml) return;
lf.setProperties(node.id, {
...(node.properties || {}),
text: {
content: nextHtml,
rich: true
}
});
};
</script>
<template>
<div class="property-section">
<div class="section-header">文本节点</div>
<div class="property-item">
<label class="property-label">内容</label>
<div class="property-value">
{{ props.node?.properties?.text || '未设置' }}
</div>
</div>
<div class="property-item">
<div class="property-note">
💡 提示双击画布中的节点即可编辑文字
<div class="editor-wrapper">
<QuillEditor
:content="editorHtml"
contentType="html"
theme="snow"
:toolbar="toolbarOptions"
@update:content="handleContentChange"
/>
</div>
</div>
</div>
</template>
<style scoped>
.editor-wrapper {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.editor-wrapper :deep(.ql-toolbar.ql-snow) {
border: none;
border-bottom: 1px solid #ebeef5;
}
.editor-wrapper :deep(.ql-container.ql-snow) {
border: none;
min-height: 180px;
}
.editor-wrapper :deep(.ql-editor) {
min-height: 180px;
}
</style>