feat: add toolbar for mind

pull/59/head
fantasticit 2022-05-21 22:09:25 +08:00
parent 6550d7cb14
commit 65b7a1451a
6 changed files with 500 additions and 233 deletions

View File

@ -1,86 +1,35 @@
import { IconHelpCircle, IconMinus, IconPlus } from '@douyinfe/semi-icons'; import { Modal, Spin, Typography } from '@douyinfe/semi-ui';
import { Button, Descriptions, Modal, Popover, Space, Spin, Typography } from '@douyinfe/semi-ui';
import cls from 'classnames';
import { IconDrawBoard, IconMindCenter, IconStructure } from 'components/icons';
import { Tooltip } from 'components/tooltip';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { load, renderMind } from 'thirtypart/kityminder'; import { load, renderMind } from 'thirtypart/kityminder';
import { Editor } from 'tiptap/editor'; import { Editor } from 'tiptap/editor';
import { clamp } from 'tiptap/prose-utils';
import { cancelSubject, OPEN_MIND_SETTING_MODAL, subject } from '../_event'; import { cancelSubject, OPEN_MIND_SETTING_MODAL, subject } from '../_event';
import { MAX_ZOOM, MIN_ZOOM, TEMPLATES, THEMES, ZOOM_STEP } from './constant';
import styles from './style.module.scss'; import styles from './style.module.scss';
import { Toolbar } from './toolbar';
type IProps = { editor: Editor }; type IProps = { editor: Editor };
const { Text } = Typography; const { Text } = Typography;
const HELP_MESSAGE = [
{ key: '新增同级节点', value: 'Enter 键' },
{ key: '新增子节点', value: 'Tab 键' },
{ key: '编辑节点文字', value: '双击节点' },
{ key: '编辑节点菜单', value: '在节点右键' },
];
const HELP_MESSAGE_STYLE = {
width: '200px',
};
export const MindSettingModal: React.FC<IProps> = ({ editor }) => { export const MindSettingModal: React.FC<IProps> = ({ editor }) => {
const $mind = useRef(null); const [mind, setMind] = useState(null);
const [initialData, setInitialData] = useState({ template: '', theme: '' }); const [initialData, setInitialData] = useState({});
const [template, setTemplateState] = useState('');
const [theme, setThemeState] = useState('');
const [visible, toggleVisible] = useToggle(false); const [visible, toggleVisible] = useToggle(false);
const [loading, toggleLoading] = useToggle(true); const [loading, toggleLoading] = useToggle(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const setZoom = useCallback((type: 'minus' | 'plus') => { const renderMindEditor = useCallback(
return () => {
const mind = $mind.current;
if (!mind) return;
const currentZoom = mind.getZoomValue();
const nextZoom = clamp(type === 'minus' ? currentZoom - ZOOM_STEP : currentZoom + ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
mind.zoom(nextZoom);
};
}, []);
const setCenter = useCallback(() => {
const mind = $mind.current;
if (!mind) return;
mind.execCommand('camera');
}, []);
const setTemplate = useCallback((template) => {
const mind = $mind.current;
if (!mind) return;
mind.execCommand('template', template);
setTemplateState(template);
}, []);
const setTheme = useCallback((theme) => {
const mind = $mind.current;
if (!mind) return;
mind.execCommand('theme', theme);
setThemeState(theme);
}, []);
const setMind = useCallback(
(div) => { (div) => {
if (!div) return; if (!div) return;
if ($mind.current) { const mindInstance = renderMind({
$mind.current.destroy();
$mind.current = null;
}
$mind.current = renderMind({
container: div, container: div,
data: initialData, data: initialData,
isEditable: true, isEditable: true,
}); });
setMind(mindInstance);
}, },
[initialData] [initialData]
); );
@ -89,32 +38,23 @@ export const MindSettingModal: React.FC<IProps> = ({ editor }) => {
load() load()
.catch(setError) .catch(setError)
.finally(() => toggleLoading(false)); .finally(() => toggleLoading(false));
return () => {
if ($mind.current) {
$mind.current.destroy();
$mind.current = null;
}
};
}, [toggleLoading]); }, [toggleLoading]);
const save = useCallback(() => { const save = useCallback(() => {
if (!$mind.current) { if (!mind) {
toggleVisible(false); toggleVisible(false);
return; return;
} }
const data = $mind.current.exportJson(); const data = mind.exportJson();
editor.chain().focus().setMind({ data }).run(); editor.chain().focus().setMind({ data }).run();
toggleVisible(false); toggleVisible(false);
}, [editor, toggleVisible]); }, [editor, toggleVisible, mind]);
useEffect(() => { useEffect(() => {
const handler = (data) => { const handler = (data) => {
toggleVisible(true); toggleVisible(true);
if (data) { if (data) {
setInitialData(data.data); setInitialData(data.data);
setTemplateState(data.data.template);
setThemeState(data.data.theme);
} }
}; };
@ -151,116 +91,10 @@ export const MindSettingModal: React.FC<IProps> = ({ editor }) => {
</Spin> </Spin>
)} )}
{error && <Text>{(error && error.message) || '未知错误'}</Text>} {error && <Text>{(error && error.message) || '未知错误'}</Text>}
<div style={{ height: '100%', maxHeight: '100%', overflow: 'hidden' }} ref={setMind}></div> <div style={{ height: '100%', maxHeight: '100%', overflow: 'hidden' }} ref={renderMindEditor}></div>
<div className={styles.toolbarWrap}> <div className={styles.toolbarWrap}>
<Space> <Toolbar mind={mind} />
<Popover
zIndex={10000}
spacing={10}
style={{ padding: '0 12px 12px', overflow: 'hidden' }}
position="bottomLeft"
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' }}
position="bottomLeft"
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="居中">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconMindCenter style={{ fontSize: '0.85em' }} />}
onClick={setCenter}
/>
</Tooltip>
<Tooltip content="缩小">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconMinus style={{ fontSize: '0.85em' }} />}
onClick={setZoom('minus')}
/>
</Tooltip>
<Tooltip content="放大">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconPlus style={{ fontSize: '0.85em' }} />}
onClick={setZoom('plus')}
/>
</Tooltip>
<Popover
zIndex={10000}
spacing={10}
style={{ padding: '0 12px 12px', overflow: 'hidden' }}
position="bottomLeft"
content={
<section className={styles.sectionWrap}>
<Descriptions data={HELP_MESSAGE} style={HELP_MESSAGE_STYLE} />
</section>
}
>
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconHelpCircle style={{ fontSize: '0.85em' }} />}
/>
</Popover>
</Space>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@ -1,61 +1,13 @@
.toolbarWrap { .toolbarWrap {
position: absolute; position: absolute;
right: 24px; top: 0;
bottom: 24px; left: 0;
z-index: 100; z-index: 100;
padding: 2px 4px; width: 100%;
padding: 6px 24px;
overflow-x: auto; overflow-x: auto;
color: #fff; color: #fff;
background-color: var(--semi-color-nav-bg); background-color: var(--semi-color-nav-bg);
border: 1px solid var(--semi-color-border); border-bottom: 1px solid var(--semi-color-border);
border-radius: 3px;
align-items: center; align-items: center;
box-shadow: var(--box-shadow);
}
.sectionWrap {
margin-top: 16px;
> div {
display: flex;
width: 168px;
margin-top: 8px;
flex-wrap: wrap;
ul {
display: flex;
padding: 0;
margin: 0;
list-style: none;
flex-wrap: wrap;
li {
width: 80px;
height: 30px;
padding: 0 5px;
font-size: 12px;
line-height: 30px;
text-align: center;
text-decoration: none;
cursor: pointer;
border: 1px solid rgb(28 31 35 / 8%);
* {
font-size: inherit;
}
&.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

@ -0,0 +1,56 @@
import { IconFile } from '@douyinfe/semi-icons';
import { Button, Dropdown, Form, Tooltip } from '@douyinfe/semi-ui';
import { FormApi } from '@douyinfe/semi-ui/lib/es/form';
import { Upload } from 'components/upload';
import { useCallback, useEffect, useRef, useState } from 'react';
export const Image = ({ disabled, image, setImage }) => {
const $form = useRef<FormApi>();
const [initialState, setInitialState] = useState({ image });
const setImageUrl = useCallback((url) => {
$form.current.setValue('image', url);
}, []);
const handleOk = useCallback(() => {
$form.current.validate().then((values) => {
setImage(values.image);
});
}, [setImage]);
useEffect(() => {
setInitialState({ image });
}, [image]);
return (
<Dropdown
stopPropagation
zIndex={10000}
trigger="click"
position={'bottomLeft'}
render={
<div style={{ padding: 12 }}>
<Form
initValues={initialState}
getFormApi={(formApi) => ($form.current = formApi)}
labelPosition="left"
onSubmit={handleOk}
layout="horizontal"
>
<Form.Input autofocus label="图片" field="image" placeholder="请输入图片地址" />
<Upload onOK={setImageUrl} />
<Button type="primary" htmlType="submit" style={{ marginLeft: 12 }}>
</Button>
</Form>
</div>
}
>
<span style={{ display: 'inline-block' }}>
<Tooltip content="设置图片" zIndex={10000}>
<Button disabled={disabled} type="tertiary" theme={image ? 'light' : 'borderless'} icon={<IconFile />} />
</Tooltip>
</span>
</Dropdown>
);
};

View File

@ -0,0 +1,46 @@
.sectionWrap {
margin-top: 16px;
> div {
display: flex;
width: 168px;
margin-top: 8px;
flex-wrap: wrap;
ul {
display: flex;
padding: 0;
margin: 0;
list-style: none;
flex-wrap: wrap;
li {
width: 80px;
height: 30px;
padding: 0 5px;
font-size: 12px;
line-height: 30px;
text-align: center;
text-decoration: none;
cursor: pointer;
border: 1px solid rgb(28 31 35 / 8%);
* {
font-size: inherit;
}
&.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

@ -0,0 +1,329 @@
import { IconBold, IconFont, IconHelpCircle, IconMark } from '@douyinfe/semi-icons';
import { Button, Descriptions, Popover, Space, Tooltip, Typography } from '@douyinfe/semi-ui';
import cls from 'classnames';
import { IconDrawBoard, IconMindCenter, IconStructure } from 'components/icons';
import { IconZoomIn, IconZoomOut } from 'components/icons';
import { useToggle } from 'hooks/use-toggle';
import { useCallback, useEffect, useState } from 'react';
import { ColorPicker } from 'tiptap/components/color-picker';
import { clamp } from 'tiptap/prose-utils';
import { MAX_ZOOM, MIN_ZOOM, TEMPLATES, THEMES, ZOOM_STEP } from '../constant';
import { Image } from './image';
import styles from './index.module.scss';
import { Link } from './link';
const { Text } = Typography;
const HELP_MESSAGE = [
{ key: '新增同级节点', value: 'Enter 键' },
{ key: '新增子节点', value: 'Tab 键' },
{ key: '编辑节点文字', value: '双击节点' },
{ key: '编辑节点菜单', value: '在节点右键' },
];
const HELP_MESSAGE_STYLE = {
width: '200px',
};
export const Toolbar = ({ mind }) => {
const [template, setTemplateState] = useState('');
const [theme, setThemeState] = useState('');
const [node, setNode] = useState(null);
const [isBold, toggleIsBold] = useToggle(false);
const [textColor, setTextColor] = useState('');
const [bgColor, setBgColor] = useState('');
const [link, setLink] = useState('');
const [image, setImage] = useState('');
const setTemplate = useCallback(
(template) => {
mind.execCommand('template', template);
},
[mind]
);
const setTheme = useCallback(
(theme) => {
mind.execCommand('theme', theme);
},
[mind]
);
const setZoom = useCallback(
(type: 'minus' | 'plus') => {
return () => {
if (!mind) return;
const currentZoom = mind.getZoomValue();
const nextZoom = clamp(
type === 'minus' ? currentZoom - ZOOM_STEP : currentZoom + ZOOM_STEP,
MIN_ZOOM,
MAX_ZOOM
);
mind.zoom(nextZoom);
};
},
[mind]
);
const setCenter = useCallback(() => {
if (!mind) return;
mind.execCommand('camera');
}, [mind]);
const toggleBold = useCallback(() => {
mind.execCommand('Bold');
}, [mind]);
const setFontColor = useCallback(
(color) => {
mind.execCommand('ForeColor', color);
},
[mind]
);
const setBackgroundColor = useCallback(
(color) => {
mind.execCommand('Background', color);
},
[mind]
);
const setHyperLink = useCallback(
(url) => {
mind.execCommand('HyperLink', url);
},
[mind]
);
const insertImage = useCallback(
(url) => {
mind.execCommand('Image', url);
},
[mind]
);
useEffect(() => {
if (!mind) return;
const handler = () => {
const node = mind.getSelectedNode();
let isBold = false;
let textColor;
let bgColor;
let link;
let image;
if (node) {
isBold = mind.queryCommandState('Bold') === 1;
textColor = mind.queryCommandValue('ForeColor');
bgColor = mind.queryCommandValue('Background');
link = mind.queryCommandValue('HyperLink').url;
image = mind.queryCommandValue('Image').url;
setNode(node);
} else {
setNode(null);
}
setTemplateState(mind.queryCommandValue('Template'));
setThemeState(mind.queryCommandValue('Theme'));
toggleIsBold(isBold);
setTextColor(textColor);
setBgColor(bgColor);
setLink(link);
setImage(image);
};
mind.on('interactchange', handler);
return () => {
mind.off('interactchange', handler);
};
}, [mind, toggleIsBold, setBackgroundColor]);
return (
<Space>
<Popover
zIndex={10000}
spacing={10}
style={{ padding: '0 12px 12px', overflow: 'hidden' }}
position="bottomLeft"
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' }}
position="bottomLeft"
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="居中">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconMindCenter style={{ fontSize: '0.85em' }} />}
onClick={setCenter}
/>
</Tooltip>
<Tooltip content="缩小">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconZoomOut style={{ fontSize: '0.85em' }} />}
onClick={setZoom('minus')}
/>
</Tooltip>
<Tooltip content="放大">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconZoomIn style={{ fontSize: '0.85em' }} />}
onClick={setZoom('plus')}
/>
</Tooltip>
<Tooltip content="加粗" zIndex={10000}>
<Button
disabled={!node}
type="tertiary"
theme={isBold ? 'light' : 'borderless'}
onClick={toggleBold}
icon={<IconBold />}
/>
</Tooltip>
<ColorPicker
onSetColor={(color) => {
setFontColor(color);
}}
>
<Tooltip content="文本色" zIndex={10000}>
<Button
disabled={!node}
type="tertiary"
theme={textColor ? 'light' : 'borderless'}
icon={
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<IconFont />
<span
style={{
width: 12,
height: 2,
backgroundColor: textColor,
}}
></span>
</div>
}
/>
</Tooltip>
</ColorPicker>
<ColorPicker
onSetColor={(color) => {
setBackgroundColor(color);
}}
>
<Tooltip content="背景色" zIndex={10000}>
<Button
disabled={!node}
type="tertiary"
theme={bgColor ? 'light' : 'borderless'}
icon={
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<IconMark />
<span
style={{
width: 12,
height: 2,
backgroundColor: bgColor,
}}
></span>
</div>
}
/>
</Tooltip>
</ColorPicker>
<Link disabled={!node} link={link} setLink={setHyperLink} />
<Image disabled={!node} image={image} setImage={insertImage} />
<Popover
zIndex={10000}
spacing={10}
style={{ padding: 12, overflow: 'hidden' }}
position="bottomLeft"
content={
<section className={styles.sectionWrap}>
<Descriptions data={HELP_MESSAGE} style={HELP_MESSAGE_STYLE} />
</section>
}
>
<Button size="small" theme="borderless" type="tertiary" icon={<IconHelpCircle />} />
</Popover>
</Space>
);
};

View File

@ -0,0 +1,50 @@
import { IconLink } from '@douyinfe/semi-icons';
import { Button, Dropdown, Form, Tooltip } from '@douyinfe/semi-ui';
import { FormApi } from '@douyinfe/semi-ui/lib/es/form';
import { useCallback, useEffect, useRef, useState } from 'react';
export const Link = ({ disabled, link, setLink }) => {
const $form = useRef<FormApi>();
const [initialState, setInitialState] = useState({ link });
const handleOk = useCallback(() => {
$form.current.validate().then((values) => {
setLink(values.link);
});
}, [setLink]);
useEffect(() => {
setInitialState({ link });
}, [link]);
return (
<Dropdown
stopPropagation
zIndex={10000}
trigger="click"
position={'bottomLeft'}
render={
<div style={{ padding: 12 }}>
<Form
initValues={initialState}
getFormApi={(formApi) => ($form.current = formApi)}
labelPosition="left"
onSubmit={handleOk}
layout="horizontal"
>
<Form.Input autofocus label="链接" field="link" placeholder="请输入外链地址" />
<Button type="primary" htmlType="submit">
</Button>
</Form>
</div>
}
>
<span style={{ display: 'inline-block' }}>
<Tooltip content="设置链接" zIndex={10000}>
<Button disabled={disabled} type="tertiary" theme={link ? 'light' : 'borderless'} icon={<IconLink />} />
</Tooltip>
</span>
</Dropdown>
);
};