mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 15:05:27 +00:00
feat(flow): migrate text node to quill rendering and transparent default style
This commit is contained in:
@@ -47,7 +47,10 @@ const componentGroups = [
|
||||
type: 'textNode',
|
||||
description: '可编辑文本的节点',
|
||||
data: {
|
||||
text: '双击编辑文字',
|
||||
text: {
|
||||
content: '<p>请输入文本</p>',
|
||||
rich: true
|
||||
},
|
||||
width: 200,
|
||||
height: 120
|
||||
}
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user