tiptap: add drawio flow support

This commit is contained in:
fantasticit 2022-05-11 14:05:11 +08:00
parent c70846a8ba
commit 0688362cc0
25 changed files with 36401 additions and 2 deletions

View File

@ -4,3 +4,4 @@ node_modules
**/dist/**
.eslintrc.js
./packages/client/src/tiptap/wrappers/mind/mind-elixir/iconfont/iconfont.js
./packages/client/public/drawio.embed.js

View File

@ -1,3 +1,8 @@
interface Window {
// 思维导图
MindElixir: any;
// drawio 绘图
mxGraph: any;
mxUtils: any;
mxCodec: any;
}

View File

@ -11,6 +11,7 @@ const nextConfig = semi({
SERVER_API_URL: config.client.apiUrl,
COLLABORATION_API_URL: config.client.collaborationUrl,
ENABLE_ALIYUN_OSS: !!config.oss.aliyun.accessKeyId,
DRAWIO_URL: config.client.drawioUrl || 'https://embed.diagrams.net',
},
webpack: (config, { dev, isServer }) => {
config.resolve.plugins.push(new TsconfigPathsPlugin());

View File

@ -70,6 +70,7 @@
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"next": "12.0.10",
"pako": "^2.0.4",
"prosemirror-markdown": "^1.7.0",
"prosemirror-tables": "^1.1.1",
"prosemirror-utils": "^0.9.6",

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,17 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconFlow: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg width="16" height="16" viewBox="0 0 24 24" role="presentation">
<path
d="M15.722 1.55a2 2 0 0 1 2.828 0l3.536 3.536a2 2 0 0 1 0 2.828L18.55 11.45a2 2 0 0 1-2.828 0l-3.536-3.536a2.003 2.003 0 0 1-.318-.414H7.5a1 1 0 0 0-1 1V16a1 1 0 0 0 1 1H11v-1a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-1H7.5a3 3 0 0 1-3-3v-3.181H1.91a.91.91 0 1 1 0-1.819H4.5V8.5a3 3 0 0 1 3-3h4.368c.085-.148.192-.288.318-.414l3.536-3.536Zm1.414 1.414L13.601 6.5l3.535 3.536L20.672 6.5l-3.536-3.536ZM20 16h-7v4h7v-4Z"
fill="currentColor"
></path>
</svg>
}
/>
);
};

View File

@ -52,11 +52,10 @@ export * from './IconCountdown';
export * from './IconDrawBoard';
export * from './IconCallout';
export * from './IconStructure';
export * from './IconMindLeft';
export * from './IconMindRight';
export * from './IconMindSide';
export * from './IconMindFull';
export * from './IconMindCenter';
export * from './IconTableOfContents';
export * from './IconFlow';

View File

@ -14,6 +14,7 @@ function MyApp({ Component, pageProps }: AppProps) {
<>
<Head>
<meta name="viewport" content="viewport-fit=cover" />
<script src="/drawio.embed.js"></script>
</Head>
<Component {...pageProps} />
</>

View File

@ -0,0 +1,106 @@
import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { FlowWrapper } from 'tiptap/core/wrappers/flow';
import { getDatasetAttribute } from 'tiptap/prose-utils';
export interface IFlowAttrs {
width?: number | string;
height?: number;
data?: string;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
flow: {
setFlow: (attrs?: IFlowAttrs) => ReturnType;
};
}
}
export const Flow = Node.create({
name: 'flow',
group: 'block',
selectable: true,
atom: true,
inline: false,
addAttributes() {
return {
width: {
default: null,
parseHTML: getDatasetAttribute('width'),
},
height: {
default: 240,
parseHTML: getDatasetAttribute('height'),
},
data: {
default: '',
parseHTML: getDatasetAttribute('data'),
},
};
},
addOptions() {
return {
HTMLAttributes: {
class: 'flow',
},
};
},
parseHTML() {
return [
{
tag: 'div',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addCommands() {
return {
setFlow:
(options) =>
({ tr, commands, chain, editor }) => {
options = options || {};
options.data = options.data || '';
// @ts-ignore
if (tr.selection?.node?.type?.name == this.name) {
return commands.updateAttributes(this.name, options);
}
const { selection } = editor.state;
const pos = selection.$head;
return chain()
.insertContentAt(pos.before(), [
{
type: this.name,
attrs: options,
},
])
.run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(FlowWrapper);
},
addInputRules() {
return [
nodeInputRule({
find: /^\$flow $/,
type: this.type,
getAttributes: (match) => {
return { type: match[1] };
},
}),
];
},
});

View File

@ -34,6 +34,7 @@
.node-image,
.node-katex,
.node-mind,
.node-flow,
.node-codeBlock,
.node-documentChildren,
.node-documentReference {

View File

@ -26,6 +26,7 @@
.node-iframe,
.node-katex,
.node-mind,
.node-flow,
.node-codeBlock,
.node-documentChildren,
.node-documentReference,

View File

@ -0,0 +1,75 @@
import pako from 'pako';
function parseXml(xml) {
if (window.DOMParser) {
const parser = new DOMParser();
return parser.parseFromString(xml, 'text/xml');
} else {
const result = createXmlDocument();
result.async = 'false';
result.loadXML(xml);
return result;
}
}
function createXmlDocument() {
let doc = null;
if (document.implementation && document.implementation.createDocument) {
doc = document.implementation.createDocument('', '', null);
}
return doc;
}
function getTextContent(node) {
return node != null ? node[node.textContent === undefined ? 'text' : 'textContent'] : '';
}
export function decode(data) {
try {
const node = parseXml(data).documentElement;
if (node != null && node.nodeName == 'mxfile') {
const diagrams = node.getElementsByTagName('diagram');
if (diagrams.length > 0) {
data = getTextContent(diagrams[0]);
}
}
} catch (e) {
// ignore
}
try {
data = atob(data);
} catch (e) {
console.log(e);
alert('atob failed: ' + e);
return;
}
try {
data = pako.inflateRaw(
Uint8Array.from(data, (c) => String(c).charCodeAt(0)),
{
to: 'string',
}
);
} catch (e) {
console.log(e);
alert('inflateRaw failed: ' + e);
return;
}
try {
data = decodeURIComponent(data);
} catch (e) {
console.log(e);
alert('decodeURIComponent failed: ' + e);
return;
}
return data;
}

View File

@ -0,0 +1,31 @@
.wrap {
position: relative;
max-width: 100%;
overflow: visible;
line-height: 0;
.renderWrap {
border: 1px solid var(--node-border-color);
border-radius: var(--border-radius);
&::after {
background-color: transparent !important;
}
}
.toolbarWrap {
position: absolute;
right: 20px;
bottom: 20px;
z-index: 1000;
display: flex;
padding: 2px 4px;
overflow-x: auto;
color: #fff;
background-color: var(--semi-color-nav-bg);
border: 1px solid var(--semi-color-border);
border-radius: 3px;
align-items: center;
box-shadow: var(--box-shadow);
}
}

View File

@ -0,0 +1,120 @@
import { NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames';
import { Button, Space } from '@douyinfe/semi-ui';
import { IconMindCenter, IconZoomOut, IconZoomIn } from 'components/icons';
import { useCallback, useEffect, useRef } from 'react';
import { Resizeable } from 'components/resizeable';
import { getEditorContainerDOMSize, uuid } from 'tiptap/prose-utils';
import { Flow } from 'tiptap/core/extensions/flow';
import { decode } from './decode';
import styles from './index.module.scss';
const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' };
export const FlowWrapper = ({ editor, node, updateAttributes }) => {
const $container = useRef<HTMLDivElement>();
const $graph = useRef(null);
const containerId = useRef(`js-flow-container-${uuid()}`);
const isEditable = editor.isEditable;
const isActive = editor.isActive(Flow.name);
const { width: maxWidth } = getEditorContainerDOMSize(editor);
const { data, width, height } = node.attrs;
const center = useCallback(() => {
const graph = $graph.current;
if (!graph) return;
graph.fit();
graph.center(true, false);
}, []);
const zoomOut = useCallback(() => {
const graph = $graph.current;
if (!graph) return;
graph.zoomOut();
}, []);
const zoomIn = useCallback(() => {
const graph = $graph.current;
if (!graph) return;
graph.zoomIn();
}, []);
const onResize = useCallback(
(size) => {
updateAttributes({ width: size.width, height: size.height });
setTimeout(() => {
const graph = $graph.current;
if (!graph) return;
graph.fit();
graph.center(true, false);
}, 0);
},
[updateAttributes]
);
useEffect(() => {
let graph = $graph.current;
if (!graph) {
// @ts-ignore
graph = new mxGraph($container.current);
graph.resetViewOnRootChange = false;
graph.foldingEnabled = false;
graph.setTooltips(false);
graph.setEnabled(false);
graph.centerZoom = true;
$graph.current = graph;
}
const text = decode(data);
// @ts-ignore
const xmlDoc = mxUtils.parseXml(text);
// @ts-ignore
const codec = new mxCodec(xmlDoc);
codec.decode(codec.document.documentElement, graph.getModel());
setTimeout(() => {
graph.fit();
graph.center(true, false);
}, 0);
}, [data]);
return (
<NodeViewWrapper className={cls(styles.wrap, isActive && styles.isActive)}>
<Resizeable isEditable={isEditable} width={width} height={height} maxWidth={maxWidth} onChangeEnd={onResize}>
<div
ref={$container}
id={containerId.current}
className={cls(styles.renderWrap, 'render-wrapper')}
style={{ ...INHERIT_SIZE_STYLE, overflow: 'hidden' }}
></div>
<div className={styles.toolbarWrap}>
<Space spacing={2}>
<Button
type="tertiary"
theme="borderless"
size="small"
onClick={center}
icon={<IconMindCenter style={{ fontSize: '0.85em' }} />}
/>
<Button
type="tertiary"
theme="borderless"
size="small"
onClick={zoomOut}
icon={<IconZoomOut style={{ fontSize: '0.85em' }} />}
/>
<Button
type="tertiary"
theme="borderless"
size="small"
onClick={zoomIn}
icon={<IconZoomIn style={{ fontSize: '0.85em' }} />}
/>
</Space>
</div>
</Resizeable>
</NodeViewWrapper>
);
};

View File

@ -39,6 +39,7 @@ import { CodeBlock } from 'tiptap/editor/menus/code-block';
import { Countdonw } from 'tiptap/editor/menus/countdown';
import { DocumentChildren } from 'tiptap/editor/menus/document-children';
import { DocumentReference } from 'tiptap/editor/menus/document-reference';
import { Flow } from 'tiptap/editor/menus/flow';
import { Image } from 'tiptap/editor/menus/image';
import { Iframe } from 'tiptap/editor/menus/iframe';
import { Table } from 'tiptap/editor/menus/table';
@ -106,6 +107,7 @@ const _MenuBar: React.FC<{ editor: Editor }> = ({ editor }) => {
<Countdonw editor={editor} />
<DocumentChildren editor={editor} />
<DocumentReference editor={editor} />
<Flow editor={editor} />
<Image editor={editor} />
<Iframe editor={editor} />
<Table editor={editor} />

View File

@ -52,6 +52,7 @@ import { Countdown } from 'tiptap/core/extensions/countdown';
import { DocumentChildren } from 'tiptap/core/extensions/document-children';
import { DocumentReference } from 'tiptap/core/extensions/document-reference';
import { Emoji } from 'tiptap/core/extensions/emoji';
import { Flow } from 'tiptap/core/extensions/flow';
import { Iframe } from 'tiptap/core/extensions/iframe';
import { Katex } from 'tiptap/core/extensions/katex';
import { Mention } from 'tiptap/core/extensions/mention';
@ -134,6 +135,7 @@ export const CollaborationKit = [
DocumentChildren,
DocumentReference,
Emoji,
Flow,
Iframe,
Katex,
Mention,

View File

@ -12,6 +12,7 @@ const getEventEmitter = (editor: Editor): EventEmitter => {
export const OPEN_COUNT_SETTING_MODAL = 'OPEN_COUNT_SETTING_MODAL';
export const OPEN_LINK_SETTING_MODAL = 'OPEN_LINK_SETTING_MODAL';
export const OPEN_FLOW_SETTING_MODAL = 'OPEN_FLOW_SETTING_MODAL';
export const subject = (editor: Editor, eventName, handler) => {
const event = getEventEmitter(editor);
@ -32,3 +33,8 @@ export const triggerOpenLinkSettingModal = (editor: Editor, data) => {
const event = getEventEmitter(editor);
event.emit(OPEN_LINK_SETTING_MODAL, data);
};
export const triggerOpenFlowSettingModal = (editor: Editor, data) => {
const event = getEventEmitter(editor);
event.emit(OPEN_FLOW_SETTING_MODAL, data);
};

View File

@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { Space, Button } from '@douyinfe/semi-ui';
import { IconEdit, IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from 'tiptap/editor/views/bubble-menu';
import { Flow } from 'tiptap/core/extensions/flow';
import { Divider } from 'tiptap/components/divider';
import { copyNode, deleteNode } from 'tiptap/prose-utils';
import { useAttributes } from 'tiptap/editor/hooks/use-attributes';
import { triggerOpenFlowSettingModal } from '../_event';
export const FlowBubbleMenu = ({ editor }) => {
const attrs = useAttributes(editor, Flow.name, {});
const openEditLinkModal = useCallback(() => {
triggerOpenFlowSettingModal(editor, attrs);
}, [editor, attrs]);
const shouldShow = useCallback(() => editor.isActive(Flow.name), [editor]);
const copyMe = useCallback(() => copyNode(Flow.name, editor), [editor]);
const deleteMe = useCallback(() => deleteNode(Flow.name, editor), [editor]);
return (
<BubbleMenu
className={'bubble-menu'}
editor={editor}
pluginKey="flow-bubble-menu"
shouldShow={shouldShow}
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
>
<Space spacing={4}>
<Tooltip content="复制">
<Button onClick={copyMe} icon={<IconCopy />} type="tertiary" theme="borderless" size="small" />
</Tooltip>
<Tooltip content="编辑">
<Button size="small" type="tertiary" theme="borderless" icon={<IconEdit />} onClick={openEditLinkModal} />
</Tooltip>
<Divider />
<Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteMe} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" />
</Tooltip>
</Space>
</BubbleMenu>
);
};

View File

@ -0,0 +1,13 @@
import React from 'react';
import { Editor } from 'tiptap/editor';
import { FlowBubbleMenu } from './bubble';
import { FlowSettingModal } from './modal';
export const Flow: React.FC<{ editor: Editor }> = ({ editor }) => {
return (
<>
<FlowBubbleMenu editor={editor} />
<FlowSettingModal editor={editor} />
</>
);
};

View File

@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { Editor } from 'tiptap/editor';
import { useToggle } from 'hooks/use-toggle';
import { OPEN_FLOW_SETTING_MODAL, subject, cancelSubject } from '../_event';
type IProps = { editor: Editor };
export const FlowSettingModal: React.FC<IProps> = ({ editor }) => {
const $iframe = useRef<HTMLIFrameElement>();
const [initialData, setInitialData] = useState('');
const [visible, toggleVisible] = useToggle(false);
useEffect(() => {
const handler = (data) => {
toggleVisible(true);
data && setInitialData(data.data);
};
subject(editor, OPEN_FLOW_SETTING_MODAL, handler);
return () => {
cancelSubject(editor, OPEN_FLOW_SETTING_MODAL, handler);
};
}, [editor, toggleVisible]);
useEffect(() => {
const receiver = (evt) => {
if (!evt.data) {
toggleVisible(false);
return;
}
if (evt.data == 'ready') {
$iframe.current.contentWindow.postMessage(initialData, '*');
} else {
if (evt.data.length > 0) {
const data = evt.data;
editor.chain().focus().setFlow({ data }).run();
}
}
};
window.addEventListener('message', receiver);
return () => {
window.removeEventListener('message', receiver);
};
}, [editor, toggleVisible, initialData]);
return (
<Modal centered title="流程图" fullScreen visible={visible} header={null} footer={null}>
<div style={{ height: '100%', margin: '0 -24px' }}>
<iframe
ref={$iframe}
src={`${process.env.DRAWIO_URL}?embed=1&lang=zh&hide-pages=1&drafts=0&client=1&spin=0`}
style={{ width: '100%', height: '100%' }}
frameBorder={0}
></iframe>
</div>
</Modal>
);
};

View File

@ -16,6 +16,7 @@ import {
IconCountdown,
IconCallout,
IconTableOfContents,
IconFlow,
} from 'components/icons';
import { GridSelect } from 'components/grid-select';
import { useToggle } from 'hooks/use-toggle';
@ -95,6 +96,13 @@ const COMMANDS = [
{
title: '卡片',
},
{
icon: <IconFlow />,
label: '流程图',
action: (editor) => {
editor.chain().focus().setFlow({ width: '100%' }).run();
},
},
{
icon: <IconMind />,
label: '思维导图',

View File

@ -20,6 +20,7 @@ import {
IconCountdown,
IconCallout,
IconTableOfContents,
IconFlow,
} from 'components/icons';
import { createCountdown } from './countdown/service';
import { createOrToggleLink } from './link/service';
@ -201,6 +202,17 @@ export const QUICK_INSERT_ITEMS = [
command: (editor: Editor) => editor.chain().focus().setIframe({ url: '' }).run(),
},
{
key: '流程图',
label: (
<Space>
<IconFlow />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setFlow({ width: '100%' }).run(),
},
{
key: '思维导图',
label: (

View File

@ -19,6 +19,7 @@ import { Image } from 'tiptap/core/extensions/image';
import { Callout } from 'tiptap/core/extensions/callout';
import { CodeBlock } from 'tiptap/core/extensions/code-block';
import { Iframe } from 'tiptap/core/extensions/iframe';
import { Flow } from 'tiptap/core/extensions/flow';
import { Mind } from 'tiptap/core/extensions/mind';
import { Table } from 'tiptap/core/extensions/table';
import { TableOfContents } from 'tiptap/core/extensions/table-of-contents';
@ -35,6 +36,7 @@ const OTHER_BUBBLE_MENU_TYPES = [
Callout.name,
CodeBlock.name,
Iframe.name,
Flow.name,
Mind.name,
Table.name,
TableOfContents.name,

View File

@ -10,6 +10,7 @@ client:
assetPrefix: '/'
apiUrl: 'http://localhost:5001/api'
collaborationUrl: 'ws://localhost:5003'
drawioUrl: 'https://embed.diagrams.net'
# 数据库配置
db:

View File

@ -122,6 +122,7 @@ importers:
markdown-it-sub: ^1.0.0
markdown-it-sup: ^1.0.0
next: 12.0.10
pako: ^2.0.4
prosemirror-markdown: ^1.7.0
prosemirror-tables: ^1.1.1
prosemirror-utils: ^0.9.6
@ -206,6 +207,7 @@ importers:
markdown-it-sub: 1.0.0
markdown-it-sup: 1.0.0
next: 12.0.10_react-dom@17.0.2+react@17.0.2
pako: 2.0.4
prosemirror-markdown: 1.7.0
prosemirror-tables: 1.1.1
prosemirror-utils: 0.9.6_prosemirror-tables@1.1.1
@ -6986,6 +6988,10 @@ packages:
netmask: 2.0.2
dev: false
/pako/2.0.4:
resolution: {integrity: sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg==}
dev: false
/parent-module/1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}