tiptap: add excalidraw

feat/excalidraw
fantasticit 2022-08-13 14:27:54 +08:00
parent 5d328c61fc
commit cf1a234062
15 changed files with 507 additions and 3 deletions

View File

@ -11,6 +11,7 @@
"@douyinfe/semi-icons": "^2.3.1",
"@douyinfe/semi-next": "^2.3.1",
"@douyinfe/semi-ui": "^2.3.1",
"@excalidraw/excalidraw": "^0.12.0",
"@hocuspocus/provider": "^1.0.0-alpha.29",
"@think/config": "workspace:^1.0.0",
"@think/constants": "workspace:^1.0.0",

View File

@ -141,3 +141,7 @@
background-color: transparent;
}
}
.excalidraw.excalidraw-modal-container {
z-index: 1010 !important;
}

View File

@ -0,0 +1,114 @@
import { IUser } from '@think/domains';
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { ExcalidrawWrapper } from 'tiptap/core/wrappers/excalidraw';
import { getDatasetAttribute } from 'tiptap/prose-utils';
const DEFAULT_MIND_DATA = [];
export interface IExcalidrawAttrs {
defaultShowPicker?: boolean;
createUser?: IUser['id'];
width?: number | string;
height?: number;
data?: Array<any>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
excalidraw: {
setExcalidraw: (attrs?: IExcalidrawAttrs) => ReturnType;
};
}
}
export const Excalidraw = Node.create({
name: 'excalidraw',
group: 'block',
selectable: true,
atom: true,
draggable: true,
inline: false,
addAttributes() {
return {
defaultShowPicker: {
default: false,
},
createUser: {
default: null,
},
width: {
default: '100%',
parseHTML: getDatasetAttribute('width'),
},
height: {
default: 240,
parseHTML: getDatasetAttribute('height'),
},
data: {
default: DEFAULT_MIND_DATA,
parseHTML: getDatasetAttribute('data', true),
},
};
},
addOptions() {
return {
HTMLAttributes: {
class: 'mind',
},
};
},
parseHTML() {
return [
{
tag: 'div[class=mind]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addCommands() {
return {
setExcalidraw:
(options) =>
({ tr, commands, chain, editor }) => {
options = options || {};
options.data = options.data || DEFAULT_MIND_DATA;
// @ts-ignore
if (tr.selection?.node?.type?.name == this.name) {
return commands.updateAttributes(this.name, options);
}
return chain()
.insertContent({
type: this.name,
attrs: options,
})
.run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ExcalidrawWrapper);
},
addInputRules() {
return [
nodeInputRule({
find: /^\$excalidraw $/,
type: this.type,
getAttributes: () => {
return { width: '100%' };
},
}),
];
},
});

View File

@ -14,6 +14,7 @@ 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 OPEN_MIND_SETTING_MODAL = 'OPEN_MIND_SETTING_MODAL';
export const OPEN_EXCALIDRAW_SETTING_MODAL = 'OPEN_EXCALIDRAW_SETTING_MODAL';
export const subject = (editor: Editor, eventName, handler) => {
const event = getEventEmitter(editor);
@ -44,3 +45,8 @@ export const triggerOpenMindSettingModal = (editor: Editor, data) => {
const event = getEventEmitter(editor);
event.emit(OPEN_MIND_SETTING_MODAL, data);
};
export const triggerOpenExcalidrawSettingModal = (editor: Editor, data) => {
const event = getEventEmitter(editor);
event.emit(OPEN_EXCALIDRAW_SETTING_MODAL, data);
};

View File

@ -135,6 +135,14 @@ export const COMMANDS: ICommand[] = [
editor.chain().focus().setMind({ width: '100%', defaultShowPicker: true, createUser: user.id }).run();
},
},
{
isBlock: true,
icon: <IconMind />,
label: '绘图',
action: (editor, user) => {
editor.chain().focus().setExcalidraw({ width: '100%', defaultShowPicker: true, createUser: user.id }).run();
},
},
{
isBlock: true,
icon: <IconMath />,

View File

@ -0,0 +1,82 @@
import { IconCopy, IconDelete, IconEdit, IconLineHeight } from '@douyinfe/semi-icons';
import { Button, Space } from '@douyinfe/semi-ui';
import { Divider } from 'components/divider';
import { SizeSetter } from 'components/size-setter';
import { Tooltip } from 'components/tooltip';
import { useUser } from 'data/user';
import { useCallback, useEffect } from 'react';
import { BubbleMenu } from 'tiptap/core/bubble-menu';
import { Excalidraw, IExcalidrawAttrs } from 'tiptap/core/extensions/excalidraw';
import { useAttributes } from 'tiptap/core/hooks/use-attributes';
import { copyNode, deleteNode, getEditorContainerDOMSize } from 'tiptap/prose-utils';
import { triggerOpenExcalidrawSettingModal } from '../_event';
export const ExcalidrawBubbleMenu = ({ editor }) => {
const { width: maxWidth } = getEditorContainerDOMSize(editor);
const attrs = useAttributes<IExcalidrawAttrs>(editor, Excalidraw.name, {
defaultShowPicker: false,
createUser: '',
width: 0,
height: 0,
});
const { defaultShowPicker, createUser, width, height } = attrs;
const { user } = useUser();
const setSize = useCallback(
(size) => {
editor
.chain()
.updateAttributes(Excalidraw.name, size)
.setNodeSelection(editor.state.selection.from)
.focus()
.run();
},
[editor]
);
const openEditLinkModal = useCallback(() => {
triggerOpenExcalidrawSettingModal(editor, attrs);
}, [editor, attrs]);
const shouldShow = useCallback(() => editor.isActive(Excalidraw.name), [editor]);
const copyMe = useCallback(() => copyNode(Excalidraw.name, editor), [editor]);
const deleteMe = useCallback(() => deleteNode(Excalidraw.name, editor), [editor]);
useEffect(() => {
if (defaultShowPicker && user && createUser === user.id) {
openEditLinkModal();
editor.chain().updateAttributes(Excalidraw.name, { defaultShowPicker: false }).focus().run();
}
}, [createUser, defaultShowPicker, editor, openEditLinkModal, user]);
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>
<SizeSetter width={width} maxWidth={maxWidth} height={height} onOk={setSize}>
<Tooltip content="设置宽高">
<Button icon={<IconLineHeight />} type="tertiary" theme="borderless" size="small" />
</Tooltip>
</SizeSetter>
<Divider />
<Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteMe} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" />
</Tooltip>
</Space>
</BubbleMenu>
);
};

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Editor } from 'tiptap/core';
import { ExcalidrawBubbleMenu } from './bubble';
import { ExcalidrawSettingModal } from './modal';
export const Excalidraw: React.FC<{ editor: Editor }> = ({ editor }) => {
return (
<>
<ExcalidrawBubbleMenu editor={editor} />
<ExcalidrawSettingModal editor={editor} />
</>
);
};

View File

@ -0,0 +1,106 @@
import { Modal, Spin, Typography } from '@douyinfe/semi-ui';
import { useToggle } from 'hooks/use-toggle';
import { useCallback, useEffect, useState } from 'react';
import { Editor } from 'tiptap/core';
import { cancelSubject, OPEN_EXCALIDRAW_SETTING_MODAL, subject } from '../_event';
type IProps = { editor: Editor };
const { Text } = Typography;
export const ExcalidrawSettingModal: React.FC<IProps> = ({ editor }) => {
const [Excalidraw, setExcalidraw] = useState(null);
const [elements, setElements] = useState([]);
const [initialData, setInitialData] = useState([]);
const [visible, toggleVisible] = useToggle(false);
const [loading, toggleLoading] = useToggle(true);
const [error, setError] = useState(null);
const renderEditor = useCallback(
(div) => {
if (!div) return;
import('@excalidraw/excalidraw')
.then((res) => {
setExcalidraw(res.Excalidraw);
})
.catch(setError)
.finally(() => toggleLoading(false));
},
[toggleLoading]
);
const renderExcalidraw = useCallback((app) => {
console.log('render', app);
setTimeout(() => {
app.refresh();
});
}, []);
const onChange = useCallback((els) => {
setElements(els);
}, []);
const save = useCallback(() => {
if (!Excalidraw) {
toggleVisible(false);
return;
}
if (elements.filter((el) => !el.isDeleted).length > 0) {
editor.chain().focus().setExcalidraw({ data: elements }).run();
}
toggleVisible(false);
}, [Excalidraw, editor, elements, toggleVisible]);
useEffect(() => {
const handler = (data) => {
toggleVisible(true);
data && setInitialData(data.data);
};
subject(editor, OPEN_EXCALIDRAW_SETTING_MODAL, handler);
return () => {
cancelSubject(editor, OPEN_EXCALIDRAW_SETTING_MODAL, handler);
};
}, [editor, toggleVisible]);
return (
<Modal
centered
title="绘图"
fullScreen
visible={visible}
onCancel={toggleVisible}
onOk={save}
okText="保存"
cancelText="退出"
>
<div style={{ height: '100%', margin: '0 -24px', border: '1px solid var(--semi-color-border)' }}>
{loading && (
<Spin spinning>
{/* FIXME: semi-design 的问题,不加 div文字会换行! */}
<div></div>
</Spin>
)}
{error && <Text>{(error && error.message) || '未知错误'}</Text>}
<div style={{ width: '100%', height: '100%' }} ref={renderEditor}>
{!loading && !error && Excalidraw ? (
<Excalidraw
ref={renderExcalidraw}
onChange={onChange}
langCode="zh-CN"
initialData={{
appState: { isLoading: false },
elements: initialData,
}}
/>
) : null}
</div>
</div>
</Modal>
);
};

View File

@ -38,7 +38,8 @@
.node-flow,
.node-codeBlock,
.node-documentChildren,
.node-documentReference {
.node-documentReference,
.node-excalidraw {
margin-top: 0.75em;
}
@ -60,7 +61,8 @@
.node-flow,
.node-codeBlock,
.node-documentChildren,
.node-documentReference {
.node-documentReference,
.node-excalidraw {
.render-wrapper {
position: relative;
user-select: text;

View File

@ -36,7 +36,8 @@
.node-codeBlock,
.node-documentChildren,
.node-documentReference,
.node-status {
.node-status,
.node-excalidraw {
&.selected-node {
&:not(.has-focus) {
::selection {

View File

@ -0,0 +1,33 @@
.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;
}
}
.title {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
.icon {
display: flex;
width: 18px;
height: 18px;
color: #fff;
background-color: #f80;
border-radius: 2px;
justify-content: center;
align-items: center;
}
}
}

View File

@ -0,0 +1,116 @@
import { Space, Spin, Typography } from '@douyinfe/semi-ui';
import { NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames';
import { IconMind } from 'components/icons';
import { Resizeable } from 'components/resizeable';
import { useToggle } from 'hooks/use-toggle';
import { useCallback, useEffect, useRef, useState } from 'react';
import VisibilitySensor from 'react-visibility-sensor';
import { Excalidraw } from 'tiptap/core/extensions/excalidraw';
import { getEditorContainerDOMSize } from 'tiptap/prose-utils';
import styles from './index.module.scss';
const { Text } = Typography;
const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' };
export const ExcalidrawWrapper = ({ editor, node, updateAttributes }) => {
const exportToSvgRef = useRef(null);
const isEditable = editor.isEditable;
const isActive = editor.isActive(Excalidraw.name);
const { width: maxWidth } = getEditorContainerDOMSize(editor);
const { data, width, height } = node.attrs;
const [Svg, setSvg] = useState<SVGElement | null>(null);
const [loading, toggleLoading] = useToggle(true);
const [error, setError] = useState<Error | null>(null);
const [visible, toggleVisible] = useToggle(false);
const onResize = useCallback(
(size) => {
updateAttributes({ width: size.width, height: size.height });
},
[updateAttributes]
);
const onViewportChange = useCallback(
(visible) => {
if (visible) {
toggleVisible(true);
}
},
[toggleVisible]
);
useEffect(() => {
import('@excalidraw/excalidraw')
.then((res) => {
exportToSvgRef.current = res.exportToSvg;
})
.catch(setError)
.finally(() => toggleLoading(false));
}, [toggleLoading, data]);
useEffect(() => {
const setContent = async () => {
if (loading || error || !visible || !data || !data.length) return;
const svg: SVGElement = await exportToSvgRef.current({
elements: data,
files: null,
});
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('display', 'block');
setSvg(svg);
};
setContent();
}, [data, loading, error, visible]);
return (
<NodeViewWrapper className={cls(styles.wrap, isActive && styles.isActive)}>
<VisibilitySensor onChange={onViewportChange}>
<Resizeable isEditable={isEditable} width={width} height={height} maxWidth={maxWidth} onChangeEnd={onResize}>
<div
className={cls(styles.renderWrap, 'render-wrapper')}
style={{ ...INHERIT_SIZE_STYLE, overflow: 'hidden' }}
>
{error && (
<div style={INHERIT_SIZE_STYLE}>
<Text>{error.message || error}</Text>
</div>
)}
{loading && <Spin spinning style={INHERIT_SIZE_STYLE}></Spin>}
{!loading && !error && visible && (
<div
style={{
height: '100%',
maxHeight: '100%',
padding: 24,
overflow: 'hidden',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
dangerouslySetInnerHTML={{ __html: Svg?.outerHTML ?? '' }}
/>
)}
<div className={styles.title}>
<Space>
<span className={styles.icon}>
<IconMind />
</span>
</Space>
</div>
</div>
</Resizeable>
</VisibilitySensor>
</NodeViewWrapper>
);
};

View File

@ -16,6 +16,7 @@ import { Countdonw } from 'tiptap/core/menus/countdown';
import { DocumentChildren } from 'tiptap/core/menus/document-children';
import { DocumentReference } from 'tiptap/core/menus/document-reference';
import { Emoji } from 'tiptap/core/menus/emoji';
import { Excalidraw } from 'tiptap/core/menus/excalidraw';
import { Flow } from 'tiptap/core/menus/flow';
import { FontSize } from 'tiptap/core/menus/fontsize';
import { Heading } from 'tiptap/core/menus/heading';
@ -107,6 +108,7 @@ const _MenuBar: React.FC<{ editor: Editor }> = ({ editor }) => {
<Table editor={editor} />
<Katex editor={editor} />
<Mind editor={editor} />
<Excalidraw editor={editor} />
</Space>
</div>
);

View File

@ -20,6 +20,7 @@ import { Dragable } from 'tiptap/core/extensions/dragable';
import { Dropcursor } from 'tiptap/core/extensions/dropcursor';
import { Emoji } from 'tiptap/core/extensions/emoji';
import { EventEmitter } from 'tiptap/core/extensions/event-emitter';
import { Excalidraw } from 'tiptap/core/extensions/excalidraw';
import { Flow } from 'tiptap/core/extensions/flow';
import { Focus } from 'tiptap/core/extensions/focus';
import { FontSize } from 'tiptap/core/extensions/font-size';
@ -100,6 +101,7 @@ export const CollaborationKit = [
Color,
ColorHighlighter,
Dropcursor,
Excalidraw,
EventEmitter,
Focus,
FontSize,

View File

@ -44,6 +44,7 @@ importers:
'@douyinfe/semi-icons': ^2.3.1
'@douyinfe/semi-next': ^2.3.1
'@douyinfe/semi-ui': ^2.3.1
'@excalidraw/excalidraw': ^0.12.0
'@hocuspocus/provider': ^1.0.0-alpha.29
'@think/config': workspace:^1.0.0
'@think/constants': workspace:^1.0.0
@ -156,6 +157,7 @@ importers:
'@douyinfe/semi-icons': 2.3.1_react@17.0.2
'@douyinfe/semi-next': 2.3.1
'@douyinfe/semi-ui': 2.3.1_wnecvl2xit6hykxlpfa3byfhr4
'@excalidraw/excalidraw': 0.12.0_sfoxds7t5ydpegc3knd667wn6m
'@hocuspocus/provider': 1.0.0-alpha.29
'@think/config': link:../config
'@think/constants': link:../constants
@ -1951,6 +1953,17 @@ packages:
- supports-color
dev: true
/@excalidraw/excalidraw/0.12.0_sfoxds7t5ydpegc3knd667wn6m:
resolution: {integrity: sha512-xMPmKmOEgKij43k5m6Koaevb+SBw6La7MT9UDY8Iq7nQCMhA1HQwcUURfSkZ3ERibdQmMsAGtjSLbkX7hrA3+A==}
peerDependencies:
react: ^17.0.2
react-dom: ^17.0.2
dependencies:
dotenv: 10.0.0
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
dev: false
/@hocuspocus/common/1.0.0-alpha.4:
resolution: {integrity: sha512-LvKj+ASSWnvjFB7n2bl7BUGFKF9XFFP1oA3/XmKl3c7wUIvoN1Ir3sX8XnN6qBA3S2CoruEHHk+KPS6zMSMfHA==}
dev: false