开始前在这里贴一下我们的项目地址和官网,欢迎大家访问并为我们的项目点上star⭐️~
项目地址:github.com/didi/LogicF...
官网地址:site.logic-flow.cn/
引言
便签节点是一种非常实用的辅助节点,用于在流程图中添加额外的说明、注释或备注信息。它们通常以不同于常规节点(矩形、菱形、圆形)的视觉形式呈现,例如云形框、带图标的文本框等,以帮助用户更好地理解流程图的逻辑、功能和背景。
🤔便签都有哪些特征呢?
为了搞清楚这个问题,小编研究了一些自己常用的流程图编辑工具,发现它们的便签节点一般具备以下三个特征:
这里以 ProcessOn 和 FigJam 为例
特点\示例 | ProcessOn | FigJam |
---|---|---|
样式仿真便利贴:看起来像真实的便签贴纸,清晰直观。 | ||
高度随内容变化:输入内容越多,节点自动扩展,方便展示。 | ||
支持富文本编辑:能插入格式化的文字,比如加粗、斜体或链接。 |
今天,就让我们一起照葫芦画瓢,为 LogicFlow 添加便签节点,让流程图更生动、实用,先看效果:
👨💻三步实现这个便签
第一步:画出便利贴样式
由于 LogicFlow 的底层基于 SVG 实现,因此在绘制便签样式时,我们有两个选择:
- 使用 SVG 的基础图形组合实现样式(如矩形 + 文本)。
- 通过 SVG 的 foreignObject 标签嵌套 HTML 元素实现样式。
虽然第二种方式功能强大,但需要额外处理 foreignObject 和 HTML 宽高联动的逻辑,会增加复杂度。
因此,我们选择第一种思路,直接用 SVG 图形绘制便利贴样式。 我们继承 LogicFlow 的矩形节点并进行自定义:
- 引入依赖:便签节点的基本形状为矩形,因此我们需要引入 LogicFlow 的矩形节点( RectNode 和 RectNodeModel ),并通过继承的方式添加自定义功能。此外,使用 LogicFlow 提供的 h 函数来创建自定义图形节点。
typescript
import LogicFlow, { RectNode, RectNodeModel, h } from '@logicflow/core';
- 定义节点视图:通过继承 RectNode,重写其 getShape 方法,生成一个带折角的便签图形,折角的尺寸可以通过 cornerSize 属性控制。
typescript
export class NoteView extends RectNode { // 继承矩形节点的视图类
getShape() { // 重写getShape方法,返回一个带折角的矩形
const { model } = this.props;
const {
x, // 节点x坐标
y, // 节点y坐标
width, // 节点宽度
height, // 节点高度
cornerSize // 自定义属性:便签折角尺寸
} = model;
const style = model.getNodeStyle();
const strokeWidth = style.strokeWidth || 1;
// 起点坐标,因为stroke是图形的外边框,添加后图形会被向右下方挤压
// 为了能让边框完整的显示出来,所以把起点设置在边框中间
const startPosition = strokeWidth / 2;
const noteMainPath = ` // 便签图形
${startPosition},${startPosition}
${startPosition + width},${startPosition}
${startPosition + width},${startPosition + height - cornerSize}
${startPosition + width - cornerSize},${startPosition + height}
${startPosition},${startPosition + height}`;
const noteCornerPath = ` // 便签折角图形
${width - cornerSize},${startPosition + height - cornerSize}
${width},${height - cornerSize}
${width - cornerSize},${height}`;
return h( // 返回便签形状
'svg',
{
...style,
x: x - width / 2,
y: y - height / 2,
width: width + strokeWidth,
height: height + strokeWidth,
viewBox: `-1 -1 ${width + strokeWidth + 2} ${height + strokeWidth + 2}`,
},
[
h('polygon', { // 便签图形
points: noteMainPath,
fill: style.fill,
stroke: style.stroke,
strokeWidth,
}),
h('polygon', { // 便签折角图形
points: noteCornerPath,
fill: style.fill,
stroke: style.stroke,
strokeWidth,
}),
]
);
}
}
- 定义节点数据模型:继承 RectNodeModel 并重写 initNodeData 和 getNodeStyle 方法,以初始化节点数据和样式。
typescript
export class NoteModel extends RectNodeModel {
initNodeData(data: LogicFlow.NodeConfig<LogicFlow.PropertiesType>) { // 初始化节点数据
super.initNodeData(data);
const { properties } = data;
if (!properties) return;
// 给节点的宽高、折角尺寸赋值
const { width, height } = properties;
this.width = width || this.width;
this.height = height || this.height;
this.defaultHeight = height;
this.cornerSize = Math.min(this.width, this.height) * 0.2;
}
getNodeStyle() { // 重写节点样式
const { cornerSize } = this;
const style = super.getNodeStyle();
const { style: { fill, stroke, strokeWidth } = {} } = this.properties;
style.fill = fill || '#fff';
style.stroke = stroke || '#2961EF';
style.strokeWidth = strokeWidth || cornerSize / 20;
return style;
}
}
这样,我们可以动态调整便签节点的宽高、折角尺寸及样式,使节点外观更符合需求。
- 声明节点类型:将定义好的 NoteView 和 NoteModel 注册为 LogicFlow 的自定义节点类型。
typescript
export const note = {
type: 'note',
view: NoteView,
model: NoteModel,
}
现在,我们就成功自定义一个带折角的便签节点啦!它具有以下特点:
- 灵活的图形设计:支持自定义宽高和折角尺寸。
- 完善的样式配置:通过重写 getNodeStyle 方法控制填充色、边框色等样式细节。
- 便于扩展:基于 LogicFlow 的节点继承机制,便于进一步增加功能(如富文本编辑、动态调整等)。
可以看到这时的便签节点还只支持基础的文本输入能力,没办法做富文本编辑。 所以接下来,我们来为便签增加富文本文本编辑能力。
第二步:加入富文本编辑能力
在实现富文本编辑功能之前,我们先了解一下流程图框架中文本编辑的常见设计方法。
🧐流程图框架的文本编辑功能是如何实现的?
以 LogicFlow 为例,由于 SVG 本身不支持输入框,LogicFlow 的文本编辑功能通过以下方式实现:
- 双层结构:在 SVG 层显示静态文本,在其上方的工具层用 div 实现可编辑的文本框。
- 动态切换:双击文本后,工具层的 div 替代静态文本成为编辑态,用户完成输入后再同步回 SVG 层。
这种设计兼顾了文本的展示与编辑需求,也是我们便签节点富文本功能的基础。
要加入富文本编辑能力,首先要考虑的是富文本编辑器如何实现,为了避免重复造轮子,我们选择了开源的富文本编辑器 medium-editor ,并在此基础上封装了一个 Label 插件来实现富文本功能。实现时只需要引入 Label 插件就能增加富文本编辑能力:
typescript
const lf = new LogicFlow({
...config,
plugins: [Label, DndPanel],
pluginsOptions: {
label: {
isMultiple: true, // 是否支持一个元素绑定多个label
maxCount: 3, // 一个元素最多能绑定多少个label
labelWidth: 80, // label文本框的宽度
textOverflowMode: 'wrap', // 文本换行模式,支持的选项:'ellipsis' | 'wrap' | 'clip' | 'nowrap' | 'default'
},
},
})
这里对 Label 插件的内部实现做简单的介绍,大家可以根据自己的喜好选择用某个现成的库还是自行开发~
小课堂:Label插件内部逻辑简介
Label插件是参考Text功能的逻辑实现了基础的文本输入和展示能力,在此基础上引入了 medium-editor 作为富文本编辑器,主要有三个核心逻辑。
文本状态变更
LogicFlow 的节点具有两种状态:展示态 和 文本编辑态。当用户双击处于展示态的节点时,节点会切换为文本编辑态,并触发 node:dbclick 事件,通知外部该节点被双击。
Label 插件会监听 node:dbclick 事件并进行处理:当事件触发时,插件会判断双击位置是否包含 Label。
- 如果存在 Label,则将其状态切换为编辑态,并将节点中存储的 Label 内容回填到编辑框中;
- 如果不存在,则会在工具层创建一个 contenteditable 属性为 true 的 div,用作新的编辑框,供用户直接输入内容。
添加富文本编辑器
插件内部根据 medium-editor 的语法声明编辑器配置 defaultOptions ,然后在工具层挂载时传入 需要监听的 DOM 的类名 和 编辑器配置 创建编辑器实例,挂到工具层,就能实现选中元素出现编辑器的效果了。
typescript
const defaultOptions = { // medium-editor的默认配置
toolbar: { // 工具栏配置
allowMultiParagraphSelection: true,
buttons: [
'bold',
'colorpicker',
'italic',
'underline',
'strikethrough',
'quote',
'justifyLeft',
'justifyCenter',
'justifyRight',
'justifyFull',
'superscript',
'subscript',
'orderedlist',
'unorderedlist',
'pre',
'removeFormat',
'outdent',
'indent',
'h2',
'h3',
],
standardizeSelectionStart: false,
updateOnEmptySelection: false,
},
placeholder: {
text: '请输入内容',
hideOnClick: true,
},
disableEditing: true,
}
// ...
componentDidUpdate() {
// ...
this.editor?.destroy() // 为保证编辑器全局唯一,在创建前先销毁一下
this.editor = new MediumEditor( // 创建编辑器实例,挂到全局
'.lf-label-editor', // Label插件为内部输入框的类名
merge(defaultOptions, {
autoLink: true,
extensions: {
colorPicker: new ColorPickerButton(),
},
}),
)
}
数据的存储
经过富文本编辑器编辑过的内容会是这样的一个 HTML 片段,但最终在数据上我们需要存储一个纯文本数据,再加上数据存储后需要回填,所以小编用两个字段来存储便签内容:
- content:存储带样式的 HTML 片段
- text:存储纯文本内容
这里的操作很简单,在文本框失焦时获取 innerText 和 innerHTML 存储到节点里。
typescript
const value = this.textRef.current?.innerText ?? ''
const content = this.textRef.current?.innerHTML ?? ''
this.setElementModelLabelInfo({ // 把数据塞到model里
value,
content,
})
第三步:高度与内容联动变化
最后就是高度联动啦,为了让便签节点的高度随内容变化,我们需要监听输入框的高度变化,并实时更新便签节点的外观。
首先我们给输入框 div 加上 onInput 事件
HTML
<div
ref={this.textRef}
id={`editor-container-${id}`}
className={classNames('lf-label-editor', {
'lf-label-editor-dragging': isDragging,
'lf-label-editor-editing': isEditing,
'lf-label-editor-hover': !isEditing && (isHovered || isSelected),
[`lf-label-editor-${textOverflowMode}`]: !isEditing,
})}
onInput={this.handleInput}
style={{
maxWidth: `${maxLabelWidth}px`,
boxSizing: 'border-box',
display: 'inline-block',
background:
isEditing || element.BaseType === 'edge' ? '#fff' : 'transparent',
...style,
}}
dangerouslySetInnerHTML={{ __html: content }}
/>
根据内容动态调整节点和文本框的高度,使两者保持一致。
typescript
// 触发回调后的核心逻辑
const domId = `editor-container-${label.id}`;
// 获取当前输入的label容器元素
const domElement = document.getElementById(domId);
if (!domElement) return;
// 当前输入的label容器元素的内容(因为便签只有一个文本,所以内容元素即第一个子元素)
const contentDom = domElement.children[0] as HTMLElement;
if (!contentDom) return;
const lineHeight = window.getComputedStyle(contentDom).lineHeight
// 设置高度
// 为了保持图形的高度和输入框不贴边,在每次设置图形高度时都在内容高度的基础上加上1行高的高度
if (contentDom.offsetHeight > this.defaultHeight) {
this.setProperty('height', contentDom.offsetHeight + parseFloat(lineHeight));
} else {
this.setProperty('height', contentDom.offsetHeight < this.defaultHeight ? this.defaultHeight : contentDom.offsetHeight + parseFloat(lineHeight));
}
this.properties._label?.forEach((label: any) => {
label.style = {
...label.style,
minHeight: `${height - height * 0.2}px`, // 更新文本框高度
};
});
通过这个联动机制,无论便签内容多少,节点都会自动适配,保证显示效果。
结语
以上就是流程图加便签的整个实现过程,便签节点作为流程图中的一种辅助元素,不仅能够直观地传递额外信息,还为用户提供了更强的交互体验。
未来,便签节点还有更多可以探索的方向,比如支持插入图片、链接,或者提供个性化的样式和主题选项。这不仅能提升流程图的表现力,也能让用户更高效地传递和处理信息。如果你对流程图工具的开发感兴趣,欢迎你发挥自己的奇思妙想与我们一起共建~
如果这篇文章对你有帮助,欢迎关注我们的账号,我们会持续输出干货文章。也希望您能为我们的项目点上 Star,这对我们非常重要,感恩的心~
项目地址传送门:github.com/didi/LogicF...