tiptap: fix mind toolbar

This commit is contained in:
fantasticit 2022-04-27 16:23:14 +08:00
parent a9eefd3c37
commit 2ad45a880e
12 changed files with 449 additions and 423 deletions

View File

@ -1,4 +1,4 @@
export const Divider = () => { export const Divider = ({ vertical = false }) => {
return ( return (
<div <div
style={{ style={{
@ -7,6 +7,7 @@ export const Divider = () => {
height: 24, height: 24,
margin: '0 6px', margin: '0 6px',
backgroundColor: 'var(--semi-color-border)', backgroundColor: 'var(--semi-color-border)',
transform: `rotate(${vertical ? 90 : 0}deg)`,
}} }}
></div> ></div>
); );

View File

@ -1,74 +1,11 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import cls from 'classnames'; import { Space, Button } from '@douyinfe/semi-ui';
import { Space, Button, Popover, Typography } from '@douyinfe/semi-ui'; import { IconDelete } from '@douyinfe/semi-icons';
import { IconAlignCenter, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { IconStructure, IconDrawBoard, IconZoomIn, IconZoomOut } from 'components/icons';
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 { clamp } from '../../utils/clamp';
import { TEMPLATES, THEMES, MAX_ZOOM, MIN_ZOOM, ZOOM_STEP } from './constant';
import styles from './bubble.module.scss';
const { Text } = Typography;
export const MindBubbleMenu = ({ editor }) => { export const MindBubbleMenu = ({ editor }) => {
const { template, theme, zoom, callCenterCount } = 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 setCenter = useCallback(() => {
const nextValue = Number.isNaN(callCenterCount) ? 1 : Number(callCenterCount) + 1;
editor
.chain()
.updateAttributes(Mind.name, {
callCenterCount: nextValue,
})
.focus()
.run();
}, [editor, callCenterCount]);
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 (
@ -80,90 +17,6 @@ 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>
<Tooltip content="居中">
<Button size="small" type="tertiary" theme="borderless" icon={<IconAlignCenter />} onClick={setCenter} />
</Tooltip>
<Divider />
<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
key={item.label}
className={cls(template === item.value && styles.active)}
onClick={() => setTemplate(item.value)}
>
<Text>{item.label}</Text>
</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
key={item.label}
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 />
<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" />
</Tooltip> </Tooltip>

View File

@ -13,21 +13,15 @@
} }
} }
.mindHandlerWrap { .toolbarWrap {
position: absolute; position: absolute;
right: 20px; top: 50%;
bottom: 20px; left: 0;
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; opacity: 0;
box-shadow: var(--box-shadow);
} }
&.isActive { &.isActive {
.mindHandlerWrap { .toolbarWrap {
opacity: 1; opacity: 1;
} }
} }

View File

@ -1,16 +1,15 @@
import { NodeViewWrapper } from '@tiptap/react'; import { NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames'; import cls from 'classnames';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Spin, Button, Typography } from '@douyinfe/semi-ui'; import { Spin, Typography } from '@douyinfe/semi-ui';
import { IconMinus, IconPlus, IconAlignCenter } from '@douyinfe/semi-icons';
import deepEqual from 'deep-equal'; import deepEqual from 'deep-equal';
import { Resizeable } from 'components/resizeable'; import { Resizeable } from 'components/resizeable';
import { Tooltip } from 'components/tooltip';
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 { clamp } from '../../utils/clamp';
import { Mind } from '../../extensions/mind'; import { Mind } from '../../extensions/mind';
import { loadKityMinder } from './kityminder'; import { loadKityMinder } from './kityminder';
import { Toolbar } from './toolbar';
import { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from './toolbar/constant';
import styles from './index.module.scss'; import styles from './index.module.scss';
const { Text } = Typography; const { Text } = Typography;
@ -20,7 +19,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, template, theme, zoom, callCenterCount, width, height } = node.attrs; const { data, template, theme, zoom, width, height } = node.attrs;
const [loading, toggleLoading] = useToggle(true); const [loading, toggleLoading] = useToggle(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@ -55,12 +54,6 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
[updateAttributes] [updateAttributes]
); );
const setCenter = useCallback(() => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('camera');
}, []);
const setZoom = useCallback( const setZoom = useCallback(
(type: 'minus' | 'plus') => { (type: 'minus' | 'plus') => {
return () => { return () => {
@ -73,16 +66,45 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
MAX_ZOOM MAX_ZOOM
); );
minder.execCommand('zoom', nextZoom); minder.execCommand('zoom', nextZoom);
isEditable && updateAttributes({ zoom: nextZoom });
}; };
}, },
[editor, zoom] [editor, zoom, isEditable, updateAttributes]
);
const setCenter = useCallback(() => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('camera');
}, []);
// 布局
const setTemplate = useCallback(
(template) => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('template', template);
isEditable && updateAttributes({ template });
},
[updateAttributes, isEditable]
);
// 主题
const setTheme = useCallback(
(theme) => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('theme', theme);
isEditable && updateAttributes({ theme });
},
[updateAttributes, isEditable]
); );
const saveData = useCallback(() => { const saveData = useCallback(() => {
const minder = $mind.current; const minder = $mind.current;
if (!minder) return; if (!minder) return;
updateAttributes({ data: minder.exportJson() }); isEditable && updateAttributes({ data: minder.exportJson() });
}, [updateAttributes]); }, [updateAttributes, isEditable]);
// 加载依赖 // 加载依赖
useEffect(() => { useEffect(() => {
@ -113,6 +135,8 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
if (!isEditable) { if (!isEditable) {
minder.disable(); minder.disable();
} else {
minder.enable();
} }
$mind.current = minder; $mind.current = minder;
@ -125,6 +149,7 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
return () => { return () => {
if ($mind.current) { if ($mind.current) {
$mind.current.off('contentChange', onChange); $mind.current.off('contentChange', onChange);
$mind.current.destroy();
} }
}; };
}, [loading]); }, [loading]);
@ -133,14 +158,33 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
useEffect(() => { useEffect(() => {
const minder = $mind.current; const minder = $mind.current;
if (!minder) return; if (!minder) return;
const currentData = minder.exportJson(); const currentData = minder.exportJson();
const isEqual = deepEqual(currentData, data); const isEqual = deepEqual(currentData, data);
if (isEqual) return; if (isEqual) return;
// TODO: 也许刷新更好些 // TODO: 也许刷新更好些
minder.importJson(data); minder.importJson(data);
}, [data]); }, [data]);
// 启用/禁用
useEffect(() => {
const minder = $mind.current;
if (!minder) return;
if (!isEditable) {
minder.disable();
} else {
minder.enable();
}
}, [isEditable]);
// 缩放
useEffect(() => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('zoom', parseInt(zoom));
}, [zoom]);
// 布局 // 布局
useEffect(() => { useEffect(() => {
const minder = $mind.current; const minder = $mind.current;
@ -155,30 +199,6 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
minder.execCommand('theme', theme); minder.execCommand('theme', theme);
}, [theme]); }, [theme]);
// 缩放
useEffect(() => {
const minder = $mind.current;
if (!minder) return;
minder.execCommand('zoom', parseInt(zoom));
}, [zoom]);
// 启用/禁用
useEffect(() => {
const minder = $mind.current;
if (!minder) return;
if (isEditable) {
minder.enable();
} else {
minder.disable();
}
}, [isEditable]);
// 居中
useEffect(() => {
setCenter();
}, [callCenterCount]);
return ( return (
<NodeViewWrapper className={cls(styles.wrap, isMindActive && styles.isActive)}> <NodeViewWrapper className={cls(styles.wrap, isMindActive && styles.isActive)}>
{isEditable ? ( {isEditable ? (
@ -188,38 +208,19 @@ 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>
)} )}
<div className={styles.toolbarWrap}>
{!isEditable && ( <Toolbar
<div className={styles.mindHandlerWrap}> isEditable={isEditable}
<Tooltip content="缩小"> template={template}
<Button theme={theme}
size="small" zoom={zoom}
theme="borderless" setZoomMinus={setZoom('minus')}
type="tertiary" setZoomPlus={setZoom('plus')}
icon={<IconMinus style={{ fontSize: 14 }} />} setCenter={setCenter}
onClick={setZoom('minus')} setTemplate={setTemplate}
setTheme={setTheme}
/> />
</Tooltip>
<Tooltip content="放大">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconPlus style={{ fontSize: 14 }} />}
onClick={setZoom('plus')}
/>
</Tooltip>
<Tooltip content="居中">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconAlignCenter style={{ fontSize: 14 }} />}
onClick={setCenter}
/>
</Tooltip>
</div> </div>
)}
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };

View File

@ -1,7 +1,10 @@
export const loadKityMinder = async (): Promise<any> => { export const loadKityMinder = async (): Promise<any> => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
if (window.kityminder) { if (window.kityminder) {
if (window.kityminder.Editor) return; if (window.kityminder.Editor) {
console.log('无需重复');
return;
}
} }
} }

View File

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

View File

@ -19,7 +19,6 @@ define(function(require, exports, module) {
}); });
kity.extendClass(Minder, { kity.extendClass(Minder, {
disable: function () { disable: function () {
var me = this; var me = this;
//禁用命令 //禁用命令
@ -58,6 +57,6 @@ define(function(require, exports, module) {
this.setStatus('normal'); this.setStatus('normal');
me._interactChange(); me._interactChange();
} },
}); });
}); });

View File

@ -222,22 +222,23 @@ define(function (require, exports, module) {
let dx = 0; let dx = 0;
let dy = 0; let dy = 0;
if (!focusNode || focusNode.type === 'root') { // if (!focusNode || focusNode.type === 'root') {
// 默认居中 // // 默认居中
const parentNode = km.getPaper().node; // const parentNode = km.getPaper().node;
const shapeNode = km.getRoot().rc.container.node; // const shapeNode = km.getRoot().rc.container.node;
const { width: pw, height: ph, x: px, y: py } = parentNode.getBoundingClientRect(); // const { width: pw, height: ph, x: px, y: py } = parentNode.getBoundingClientRect();
const { width: sw, height: sh, x, y } = shapeNode.getBBox(); // const { width: sw, height: sh, x, y } = shapeNode.getBBox();
dx = pw / 2 - x - sw / 2; // dx = pw / 2 - x - sw / 2;
dy = ph / 2 - y - sh / 2; // dy = ph / 2 - y - sh / 2;
dragger.moveTo(new kity.Point(dx, dy), duration); // dragger.moveTo(new kity.Point(dx, dy), duration);
} else { // } else {
focusNode = focusNode || km.getRoot();
var viewport = km.getPaper().getViewPort(); var viewport = km.getPaper().getViewPort();
var offset = focusNode.getRenderContainer().getRenderBox('view'); var offset = focusNode.getRenderContainer().getRenderBox('view');
dx = viewport.center.x - offset.x - offset.width / 2; dx = viewport.center.x - offset.x - offset.width / 2;
dy = viewport.center.y - offset.y; dy = viewport.center.y - offset.y;
dragger.move(new kity.Point(dx, dy), duration); dragger.move(new kity.Point(dx, dy), duration);
} // }
this.setContentChanged(false); this.setContentChanged(false);
}, },

View File

@ -49,10 +49,10 @@ define(function(require, exports, module) {
* @Date: 2015.09.14 * @Date: 2015.09.14
*/ */
enable: function () { enable: function () {
element.setAttribute("contenteditable", true); element.setAttribute('contenteditable', true);
}, },
disable: function () { disable: function () {
element.setAttribute("contenteditable", false); element.setAttribute('contenteditable', false);
}, },
/** /**
* @Desc: hack FF下div contenteditable的光标丢失BUG * @Desc: hack FF下div contenteditable的光标丢失BUG
@ -60,8 +60,8 @@ define(function(require, exports, module) {
* @Date: 2015.10.15 * @Date: 2015.10.15
*/ */
fixFFCaretDisappeared: function () { fixFFCaretDisappeared: function () {
element.removeAttribute("contenteditable"); element.removeAttribute('contenteditable');
element.setAttribute("contenteditable", "true"); element.setAttribute('contenteditable', 'true');
element.blur(); element.blur();
element.focus(); element.focus();
}, },
@ -72,7 +72,7 @@ define(function(require, exports, module) {
*/ */
onblur: function (handler) { onblur: function (handler) {
element.onblur = handler; element.onblur = handler;
} },
}; };
receiver.selectAll(); receiver.selectAll();
@ -108,7 +108,6 @@ define(function(require, exports, module) {
}; };
var listener, jumpState; var listener, jumpState;
for (var i = 0; i < listeners.length; i++) { for (var i = 0; i < listeners.length; i++) {
listener = listeners[i]; listener = listeners[i];
// 忽略不在侦听状态的侦听器 // 忽略不在侦听状态的侦听器
if (listener.notifyState != '*' && listener.notifyState != fsm.state()) { if (listener.notifyState != '*' && listener.notifyState != fsm.state()) {
@ -140,6 +139,5 @@ define(function(require, exports, module) {
this.receiver = receiver; this.receiver = receiver;
} }
return module.exports = ReceiverRuntime; return (module.exports = ReceiverRuntime);
}); });

View File

@ -1,3 +1,22 @@
.wrap {
position: absolute;
top: 50%;
left: 1em;
z-index: 1000;
display: flex;
padding: 4px;
overflow-x: auto;
background-color: var(--semi-color-nav-bg);
border: 1px solid var(--semi-color-border);
border-radius: 3px;
transform: translateY(-50%);
flex-direction: column;
row-gap: 8px;
align-items: center;
box-shadow: var(--box-shadow);
user-select: none;
}
.sectionWrap { .sectionWrap {
margin-top: 16px; margin-top: 16px;

View File

@ -0,0 +1,157 @@
import cls from 'classnames';
import { Button, Popover, Typography } from '@douyinfe/semi-ui';
import { IconMinus, IconPlus, IconAlignCenter } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { IconStructure, IconDrawBoard, IconZoomIn, IconZoomOut } from 'components/icons';
import { Divider } from '../../../divider';
import { TEMPLATES, THEMES, MAX_ZOOM, MIN_ZOOM } from './constant';
import styles from './index.module.scss';
const { Text } = Typography;
interface IProps {
isEditable: boolean;
zoom: number | string;
template: string;
theme: string;
setZoomMinus: () => void;
setZoomPlus: () => void;
setCenter: () => void;
setTemplate: (arg: string) => void;
setTheme: (arg: string) => void;
}
export const Toolbar: React.FC<IProps> = ({
isEditable,
template,
theme,
zoom,
setZoomMinus,
setZoomPlus,
setCenter,
setTemplate,
setTheme,
}) => {
return (
<div className={styles.wrap}>
{isEditable ? (
<>
<Tooltip content="缩小" position="right">
<Button
size="small"
type="tertiary"
theme="borderless"
disabled={+zoom <= MIN_ZOOM}
icon={<IconZoomOut />}
onClick={setZoomMinus}
/>
</Tooltip>
<Text style={{ width: 26, textAlign: 'center' }}>{zoom}</Text>
<Tooltip content="放大" position="right">
<Button
size="small"
type="tertiary"
theme="borderless"
disabled={+zoom >= MAX_ZOOM}
icon={<IconZoomIn />}
onClick={setZoomPlus}
/>
</Tooltip>
<Tooltip content="居中" position="right">
<Button size="small" type="tertiary" theme="borderless" icon={<IconAlignCenter />} onClick={setCenter} />
</Tooltip>
<Popover
position="right"
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
key={item.label}
className={cls(template === item.value && styles.active)}
onClick={() => setTemplate(item.value)}
>
<Text>{item.label}</Text>
</li>
);
})}
</ul>
</div>
</section>
}
>
<Button icon={<IconStructure />} type="tertiary" theme="borderless" size="small" />
</Popover>
<Popover
position="right"
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
key={item.label}
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>
</>
) : (
<>
<Tooltip content="缩小" position="right">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconMinus style={{ fontSize: 14 }} />}
onClick={setZoomMinus}
/>
</Tooltip>
<Tooltip content="放大" position="right">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconPlus style={{ fontSize: 14 }} />}
onClick={setZoomPlus}
/>
</Tooltip>
<Tooltip content="居中" position="right">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconAlignCenter style={{ fontSize: 14 }} />}
onClick={setCenter}
/>
</Tooltip>
</>
)}
</div>
);
};