tiptap: enhance mind

pull/29/head
fantasticit 2022-04-26 16:21:07 +08:00
parent 59b2965b79
commit 337c3d172a
9 changed files with 412 additions and 16 deletions

View File

@ -0,0 +1,14 @@
import { Icon } from '@douyinfe/semi-ui';
export const IconStructure: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
return (
<Icon
style={style}
svg={
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
<path d="M896 645.888V608C896 519.808 824.256 448 736 448H512V384h32c52.928 0 96-43.072 96-96v-128C640 107.072 596.928 64 544 64h-128C363.072 64 320 107.072 320 160v128C320 340.928 363.072 384 416 384H448v64H224A160.192 160.192 0 0 0 64 608v37.888c-37.184 13.248-64 48.448-64 90.112v128c0 52.928 43.072 96 96 96h64c52.928 0 96-43.072 96-96v-128c0-52.928-43.072-96-96-96H128v-32C128 555.072 171.072 512 224 512H448v128h-32c-52.928 0-96 43.072-96 96v128c0 52.928 43.072 96 96 96h128c52.928 0 96-43.072 96-96v-128c0-52.928-43.072-96-96-96H512V512h224c52.928 0 96 43.072 96 96v32h-32c-52.928 0-96 43.072-96 96v128c0 52.928 43.072 96 96 96h64c52.928 0 96-43.072 96-96v-128c0-41.664-26.816-76.864-64-90.112zM384 288v-128a32 32 0 0 1 32-32h128a32 32 0 0 1 32 32v128a32 32 0 0 1-32 32h-128a32 32 0 0 1-32-32z m-192 448v128a32 32 0 0 1-32 32h-64a32 32 0 0 1-32-32v-128a32 32 0 0 1 32-32h64a32 32 0 0 1 32 32z m384 0v128a32 32 0 0 1-32 32h-128a32 32 0 0 1-32-32v-128a32 32 0 0 1 32-32h128a32 32 0 0 1 32 32z m320 128c0 17.6-14.4 32-32 32h-64a32.064 32.064 0 0 1-32-32v-128c0-17.6 14.4-32 32-32h64c17.6 0 32 14.4 32 32v128z"></path>
</svg>
}
/>
);
};

View File

@ -51,3 +51,4 @@ export * from './IconGlobe';
export * from './IconCountdown'; export * from './IconCountdown';
export * from './IconDrawBoard'; export * from './IconDrawBoard';
export * from './IconCallout'; export * from './IconCallout';
export * from './IconStructure';

View File

@ -6,7 +6,7 @@ import { getDatasetAttribute } from '../utils/dataset';
const DEFAULT_MIND_DATA = { const DEFAULT_MIND_DATA = {
root: { data: { text: '中心节点' }, children: [] }, root: { data: { text: '中心节点' }, children: [] },
template: 'default', template: 'default',
theme: 'fresh-blue', theme: 'classic',
version: '1.4.43', version: '1.4.43',
}; };
@ -39,6 +39,18 @@ export const Mind = Node.create({
default: DEFAULT_MIND_DATA, default: DEFAULT_MIND_DATA,
parseHTML: getDatasetAttribute('data', true), parseHTML: getDatasetAttribute('data', true),
}, },
template: {
default: 'default',
parseHTML: getDatasetAttribute('template'),
},
theme: {
default: 'classic',
parseHTML: getDatasetAttribute('theme'),
},
zoom: {
default: 100,
parseHTML: getDatasetAttribute('zoom'),
},
}; };
}, },

View File

@ -0,0 +1,42 @@
.sectionWrap {
margin-top: 16px;
> div {
display: flex;
flex-wrap: wrap;
margin-top: 8px;
width: 168px;
ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
li {
width: 80px;
height: 30px;
text-align: center;
line-height: 30px;
padding: 0 5px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
border: 1px solid rgb(28 31 35 / 8%);
&.active {
border: 1px solid rgb(0 101 255);
}
&:nth-of-type(2n) {
margin-left: 8px;
}
&:nth-of-type(n + 3) {
margin-top: 8px;
}
}
}
}
}

View File

@ -1,18 +1,63 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Space, Button, List, Popover, Typography } from '@douyinfe/semi-ui'; import cls from 'classnames';
import { Space, Button, List, Popover, Typography, RadioGroup, Radio } from '@douyinfe/semi-ui';
import { IconEdit, IconDelete } from '@douyinfe/semi-icons'; import { IconEdit, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { DataRender } from 'components/data-render'; import { IconStructure, IconDrawBoard, IconZoomIn, IconZoomOut } from 'components/icons';
import { IconDocument } from 'components/icons';
import { useWikiTocs } from 'data/wiki';
import { BubbleMenu } from '../../views/bubble-menu'; import { BubbleMenu } from '../../views/bubble-menu';
import { Mind } from '../../extensions/mind'; import { Mind } from '../../extensions/mind';
import { Divider } from '../../divider'; import { Divider } from '../../divider';
import { clamp } from '../../utils/clamp';
import { TEMPLATES, THEMES, MAX_ZOOM, MIN_ZOOM, ZOOM_STEP } from './constant';
import styles from './bubble.module.scss';
const { Text } = Typography; const { Text } = Typography;
export const MindBubbleMenu = ({ editor }) => { export const MindBubbleMenu = ({ editor }) => {
const { template, theme, zoom } = editor.getAttributes(Mind.name);
const setZoom = useCallback(
(type: 'minus' | 'plus') => {
return () => {
editor
.chain()
.updateAttributes(Mind.name, {
zoom: clamp(type === 'minus' ? parseInt(zoom) - ZOOM_STEP : parseInt(zoom) + ZOOM_STEP, MIN_ZOOM, MAX_ZOOM),
})
.focus()
.run();
};
},
[editor, zoom]
);
const setTemplate = useCallback(
(template) => {
editor
.chain()
.updateAttributes(Mind.name, {
template,
})
.focus()
.run();
},
[editor]
);
const setTheme = useCallback(
(theme) => {
editor
.chain()
.updateAttributes(Mind.name, {
theme,
})
.focus()
.run();
},
[editor]
);
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]); const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
return ( return (
@ -24,6 +69,82 @@ export const MindBubbleMenu = ({ editor }) => {
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }} tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
> >
<Space> <Space>
<Tooltip content="缩小">
<Button
size="small"
type="tertiary"
theme="borderless"
disabled={+zoom <= MIN_ZOOM}
icon={<IconZoomOut />}
onClick={setZoom('minus')}
/>
</Tooltip>
<Text style={{ width: 20, textAlign: 'center' }}>{zoom}</Text>
<Tooltip content="放大">
<Button
size="small"
type="tertiary"
theme="borderless"
disabled={+zoom >= MAX_ZOOM}
icon={<IconZoomIn />}
onClick={setZoom('plus')}
/>
</Tooltip>
<Popover
zIndex={10000}
spacing={10}
style={{ padding: '0 12px 12px', overflow: 'hidden' }}
content={
<section className={styles.sectionWrap}>
<Text type="secondary"></Text>
<div>
<ul>
{TEMPLATES.map((item) => {
return (
<li
className={cls(template === item.value && styles.active)}
onClick={() => setTemplate(item.value)}
>
{item.label}
</li>
);
})}
</ul>
</div>
</section>
}
>
<Button icon={<IconStructure />} type="tertiary" theme="borderless" size="small" />
</Popover>
<Popover
zIndex={10000}
spacing={10}
style={{ padding: '0 12px 12px', overflow: 'hidden' }}
content={
<section className={styles.sectionWrap}>
<Text type="secondary"></Text>
<div>
<ul>
{THEMES.map((item) => {
return (
<li
className={cls(theme === item.value && styles.active)}
style={item.style || {}}
onClick={() => setTheme(item.value)}
>
{item.label}
</li>
);
})}
</ul>
</div>
</section>
}
>
<Button icon={<IconDrawBoard />} type="tertiary" theme="borderless" size="small" />
</Popover>
<Divider /> <Divider />
<Tooltip content="删除节点" hideOnClick> <Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteNode} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" /> <Button onClick={deleteNode} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" />

View File

@ -0,0 +1,113 @@
export const TEMPLATES = [
{
label: '经典',
value: 'default',
},
{
label: '文件夹',
value: 'filetree',
},
{
label: '鱼骨图',
value: 'fish-bone',
},
{
label: '靠右',
value: 'right',
},
{
label: '组织',
value: 'structure',
},
{
label: '天盘',
value: 'tianpan',
},
];
export const THEMES = [
{
label: '经典',
value: 'classic',
style: {
color: 'rgb(68, 51, 0)',
background: ' rgb(233, 223, 152)',
},
},
{
label: '紧凑',
value: 'classic-compact',
style: {
color: 'rgb(68, 51, 0)',
background: ' rgb(233, 223, 152)',
},
},
{
label: '清新红',
value: 'fresh-red',
style: {
color: 'white',
background: ' rgb(191, 115, 115)',
},
},
{
label: '泥土黄',
value: 'fresh-soil',
style: {
color: 'white',
background: 'rgb(191, 147, 115)',
},
},
{
label: '文艺绿',
value: 'fresh-green',
style: {
color: 'white',
background: 'rgb(115, 191, 118)',
},
},
{
label: '天空蓝',
value: 'fresh-blue',
style: {
color: 'white',
background: 'rgb(115, 161, 191)',
},
},
{
label: '浪漫紫',
value: 'fresh-purple',
style: {
color: 'white',
background: 'rgb(123, 115, 191)',
},
},
{
label: '胭脂粉',
value: 'fresh-pink',
style: {
color: 'white',
background: 'rgb(191, 115, 148)',
},
},
{
label: '冷光',
value: 'snow',
style: {
color: '#fff',
background: 'rgb(164, 197, 192)',
},
},
{
label: '鱼骨图',
value: 'fish',
style: {
color: '#fff',
background: 'rgb(58, 65, 68)',
},
},
];
export const MIN_ZOOM = 10;
export const MAX_ZOOM = 200;
export const ZOOM_STEP = 15;

View File

@ -3,11 +3,32 @@
max-width: 100%; max-width: 100%;
overflow: visible; overflow: visible;
line-height: 0; line-height: 0;
outline: none;
.renderWrap { .renderWrap {
border: 1px solid var(--node-border-color);
border-radius: var(--border-radius);
&::after { &::after {
background-color: transparent !important; background-color: transparent !important;
} }
} }
.mindHandlerWrap {
position: absolute;
right: 20px;
bottom: 20px;
z-index: 1000;
padding: 4px 8px;
background-color: var(--semi-color-bg-2);
border: 1px solid var(--node-border-color);
border-radius: var(--border-radius);
opacity: 0;
box-shadow: var(--box-shadow);
}
&.isActive {
.mindHandlerWrap {
opacity: 1;
}
}
} }

View File

@ -1,11 +1,13 @@
import { NodeViewWrapper } from '@tiptap/react'; import { NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames'; import cls from 'classnames';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@douyinfe/semi-ui'; import { Spin, Button } from '@douyinfe/semi-ui';
import { IconMinus, IconPlus } from '@douyinfe/semi-icons'; import { IconMinus, IconPlus } from '@douyinfe/semi-icons';
import { Resizeable } from 'components/resizeable'; import { Resizeable } from 'components/resizeable';
import deepEqual from 'deep-equal'; import deepEqual from 'deep-equal';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from '../../menus/mind/constant';
import { clamp } from '../../utils/clamp';
import { Mind } from '../../extensions/mind'; import { Mind } from '../../extensions/mind';
import { loadKityMinder } from './kityminder'; import { loadKityMinder } from './kityminder';
import styles from './index.module.scss'; import styles from './index.module.scss';
@ -15,7 +17,7 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
const $mind = useRef<any>(); const $mind = useRef<any>();
const isMindActive = editor.isActive(Mind.name); const isMindActive = editor.isActive(Mind.name);
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
const { data, width, height = 100 } = node.attrs; const { data, template, theme, zoom, width, height = 100 } = node.attrs;
const [loading, toggleLoading] = useToggle(true); const [loading, toggleLoading] = useToggle(true);
const onResize = useCallback( const onResize = useCallback(
@ -25,6 +27,23 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
[updateAttributes] [updateAttributes]
); );
const setZoom = useCallback(
(type: 'minus' | 'plus') => {
return () => {
const minder = $mind.current;
if (!minder) return;
const currentZoom = minder.getZoomValue();
const nextZoom = clamp(
type === 'minus' ? currentZoom - ZOOM_STEP : currentZoom + ZOOM_STEP,
MIN_ZOOM,
MAX_ZOOM
);
minder.execCommand('zoom', nextZoom);
};
},
[editor, zoom]
);
const saveData = useCallback(() => { const saveData = useCallback(() => {
const minder = $mind.current; const minder = $mind.current;
if (!minder) return; if (!minder) return;
@ -38,12 +57,15 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
}; };
loadKityMinder().then((Editor) => { loadKityMinder().then((Editor) => {
toggleLoading(false); toggleLoading(false);
const minder = new Editor($container.current).minder;
minder.importJson(data); try {
$mind.current = minder; const minder = new Editor($container.current).minder;
minder.on('contentChange', onChange); minder.importJson(data);
// @ts-ignore $mind.current = minder;
window.minder = minder; minder.on('contentChange', onChange);
} catch (e) {
//
}
}); });
return () => { return () => {
@ -63,13 +85,42 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
minder.importData(data); minder.importData(data);
}, [data]); }, [data]);
// 布局
useEffect(() => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('template', template);
}, [template]);
// 主题
useEffect(() => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('theme', theme);
}, [theme]);
// 缩放
useEffect(() => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('zoom', parseInt(zoom));
}, [zoom]);
// 启用/禁用 // 启用/禁用
useEffect(() => { useEffect(() => {
const minder = $mind.current; const minder = $mind.current;
if (!minder) return; if (!minder) return;
if (isEditable) {
minder.enable();
} else {
minder.disable();
}
}, [isEditable]); }, [isEditable]);
const content = ( const content = loading ? (
<Spin spinning={loading} style={{ width: '100%', height: '100%' }}></Spin>
) : (
<div <div
ref={$container} ref={$container}
className={cls(styles.renderWrap, 'render-wrapper')} className={cls(styles.renderWrap, 'render-wrapper')}
@ -87,6 +138,25 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
) : ( ) : (
<div style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>{content}</div> <div style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>{content}</div>
)} )}
{!isEditable && (
<div className={styles.mindHandlerWrap}>
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconMinus style={{ fontSize: 14 }} />}
onClick={setZoom('minus')}
/>
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconPlus style={{ fontSize: 14 }} />}
onClick={setZoom('plus')}
/>
</div>
)}
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };

View File

@ -5,7 +5,9 @@ define(function (require, exports, module) {
Minder.registerInitHook(function () { Minder.registerInitHook(function () {
this.on('beforemousedown', function (e) { this.on('beforemousedown', function (e) {
this.focus(); this.focus();
// e.preventDefault(); // FIXME如果遇到事件触发问题需要检查这里
if (e.kityEvent.targetShape.__KityClassName === 'Paper') return;
e.preventDefault();
}); });
this.on('paperrender', function () { this.on('paperrender', function () {
this.focus(); this.focus();