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',
|
type: 'textNode',
|
||||||
description: '可编辑文本的节点',
|
description: '可编辑文本的节点',
|
||||||
data: {
|
data: {
|
||||||
text: '双击编辑文字',
|
text: {
|
||||||
|
content: '<p>请输入文本</p>',
|
||||||
|
rich: true
|
||||||
|
},
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 120
|
height: 120
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,95 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="text-content">
|
<div class="text-node" :style="displayContainerStyle">
|
||||||
<!-- LogicFlow 会自动渲染 text 内容 -->
|
<div class="text-content ql-editor" :style="textStyle" v-html="richHtml"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.text-node {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.text-content {
|
.text-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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;
|
box-sizing: border-box;
|
||||||
cursor: text;
|
padding: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-content:focus {
|
.text-content :deep(p) {
|
||||||
background: rgba(64, 158, 255, 0.1);
|
margin: 0 0 0.5em;
|
||||||
border-radius: 2px;
|
}
|
||||||
|
|
||||||
|
.text-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,131 +1,74 @@
|
|||||||
import { HtmlNodeModel } from '@logicflow/core';
|
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 {
|
class TextNodeModel extends HtmlNodeModel {
|
||||||
initNodeData(data: any) {
|
initNodeData(data: any) {
|
||||||
super.initNodeData(data);
|
super.initNodeData(data);
|
||||||
|
|
||||||
// 从 data 中读取宽高,支持调整大小后的持久化
|
this.width = parseSize(data?.properties?.width, 200);
|
||||||
if (data.properties?.width) {
|
this.height = parseSize(data?.properties?.height, 120);
|
||||||
this.width = data.properties.width;
|
|
||||||
} else {
|
|
||||||
this.width = 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.properties?.height) {
|
this.setProperty('width', this.width);
|
||||||
this.height = data.properties.height;
|
this.setProperty('height', this.height);
|
||||||
} else {
|
this.setProperty('text', normalizeTextProperty(data?.properties?.text));
|
||||||
this.height = 120;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算 Label 宽度
|
const hasStyle = Object.prototype.hasOwnProperty.call(data?.properties || {}, 'style');
|
||||||
const labelWidth = this.width - 20;
|
if (!hasStyle) {
|
||||||
|
this.setProperty('style', DEFAULT_TEXT_STYLE);
|
||||||
// 初始化或更新 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAttributes() {
|
setAttributes() {
|
||||||
// 设置默认尺寸(如果 initNodeData 中没有设置)
|
if (!this.width) this.width = 200;
|
||||||
if (!this.width) {
|
if (!this.height) this.height = 120;
|
||||||
this.width = 200;
|
|
||||||
}
|
|
||||||
if (!this.height) {
|
|
||||||
this.height = 120;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听节点大小变化,更新 Label 宽度
|
|
||||||
resize(deltaX: number, deltaY: number) {
|
resize(deltaX: number, deltaY: number) {
|
||||||
const result = super.resize?.(deltaX, deltaY);
|
const result = super.resize?.(deltaX, deltaY);
|
||||||
|
|
||||||
// 持久化宽高到 properties
|
|
||||||
this.setProperty('width', this.width);
|
this.setProperty('width', this.width);
|
||||||
this.setProperty('height', this.height);
|
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;
|
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;
|
export default TextNodeModel;
|
||||||
|
|||||||
@@ -1,22 +1,121 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
import { ref, watch } from 'vue';
|
||||||
node: any;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="property-section">
|
<div class="property-section">
|
||||||
<div class="section-header">文本节点</div>
|
<div class="section-header">文本节点</div>
|
||||||
|
|
||||||
<div class="property-item">
|
<div class="property-item">
|
||||||
<label class="property-label">内容</label>
|
<label class="property-label">内容</label>
|
||||||
<div class="property-value">
|
<div class="editor-wrapper">
|
||||||
{{ props.node?.properties?.text || '未设置' }}
|
<QuillEditor
|
||||||
</div>
|
:content="editorHtml"
|
||||||
</div>
|
contentType="html"
|
||||||
<div class="property-item">
|
theme="snow"
|
||||||
<div class="property-note">
|
:toolbar="toolbarOptions"
|
||||||
💡 提示:双击画布中的节点即可编辑文字
|
@update:content="handleContentChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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