Merge pull request #176 from fantasticit/fix/tiptap

This commit is contained in:
fantasticit 2022-08-19 22:42:11 +08:00 committed by GitHub
commit b318923fa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 794 additions and 246 deletions

View File

@ -24,15 +24,25 @@ export function renderMind(options: Options) {
const Editor = window.kityminder.Editor; const Editor = window.kityminder.Editor;
const editor = new Editor(options.container); const editor = new Editor(options.container);
const mind = editor.minder; const mind = editor.minder;
mind.editor = editor;
options.data && mind.importJson(options.data); options.data && mind.importJson(options.data);
if (!options.isEditable) { if (!options.isEditable) {
mind.disable(); mind.disable();
mind.setStatus('readonly');
} }
setTimeout(() => { setTimeout(() => {
mind.execCommand('camera'); mind.execCommand('camera');
if (!options.isEditable) {
const selectedNodes = mind.getSelectedNodes();
if (selectedNodes.length) {
mind.removeSelectedNodes(selectedNodes);
}
}
}, 0); }, 0);
return mind; return mind;

View File

@ -138,6 +138,9 @@ define(function (require, exports, module) {
}, },
_bindEvents: function () { _bindEvents: function () {
if (this.getStatus() === 'readonly') {
return this;
}
/* jscs:disable maximumLineLength */ /* jscs:disable maximumLineLength */
this._paper.on( this._paper.on(
'click dblclick mousedown contextmenu mouseup mousemove mouseover mousewheel DOMMouseScroll touchstart touchmove touchend dragenter dragleave drop', 'click dblclick mousedown contextmenu mouseup mousemove mouseover mousewheel DOMMouseScroll touchstart touchmove touchend dragenter dragleave drop',

View File

@ -17,6 +17,10 @@ define(function (require, exports, module) {
kity.extendClass(Minder, { kity.extendClass(Minder, {
focus: function () { focus: function () {
if (this.getStatus() === 'readonly') {
return this;
}
if (!this.isFocused()) { if (!this.isFocused()) {
var renderTarget = this._renderTarget; var renderTarget = this._renderTarget;
renderTarget.classList.add('focus'); renderTarget.classList.add('focus');

View File

@ -23,6 +23,10 @@ define(function (require, exports, module) {
kity.extendClass(Minder, { kity.extendClass(Minder, {
_initKeyReceiver: function () { _initKeyReceiver: function () {
if (this.getStatus() === 'readonly') {
return;
}
if (this._keyReceiver) return; if (this._keyReceiver) return;
var receiver = (this._keyReceiver = document.createElement('input')); var receiver = (this._keyReceiver = document.createElement('input'));
@ -34,6 +38,10 @@ define(function (require, exports, module) {
var minder = this; var minder = this;
listen(receiver, 'keydown keyup keypress copy paste blur focus input', function (e) { listen(receiver, 'keydown keyup keypress copy paste blur focus input', function (e) {
if (minder.getStatus() === 'readonly') {
return;
}
switch (e.type) { switch (e.type) {
case 'blur': case 'blur':
minder.blur(); minder.blur();
@ -50,14 +58,24 @@ define(function (require, exports, module) {
}); });
this.on('focus', function () { this.on('focus', function () {
if (this.getStatus() === 'readonly') {
return;
}
receiver.select(); receiver.select();
receiver.focus(); receiver.focus();
}); });
this.on('blur', function () { this.on('blur', function () {
if (this.getStatus() === 'readonly') {
return;
}
receiver.blur(); receiver.blur();
}); });
if (this.isFocused()) { if (this.isFocused()) {
if (this.getStatus() === 'readonly') {
return;
}
receiver.select(); receiver.select();
receiver.focus(); receiver.focus();
} }

View File

@ -142,6 +142,8 @@ define(function (require, exports, module) {
initEvent: function (node) { initEvent: function (node) {
this.on('mousedown', function (e) { this.on('mousedown', function (e) {
if (minder._status === 'readonly') return;
minder.select([node], true); minder.select([node], true);
if (node.isExpanded()) { if (node.isExpanded()) {
node.collapse(); node.collapse();

View File

@ -82,7 +82,7 @@ define(function (require, exports, module) {
mask.fill(color[0]); mask.fill(color[0]);
} }
number.setContent(value); number.setContent('P' + value);
}, },
}); });

View File

@ -21,9 +21,11 @@ define(function (require, exports, module) {
var receiverElement = receiver.element; var receiverElement = receiver.element;
var isGecko = window.kity.Browser.gecko; var isGecko = window.kity.Browser.gecko;
// setup everything to go // setup everything to go
setupReciverElement(); setupReciverElement();
setupFsm(); setupFsm();
setupHotbox(); setupHotbox();
// expose editText() // expose editText()
this.editText = editText; this.editText = editText;
// listen the fsm changes, make action. // listen the fsm changes, make action.
@ -32,6 +34,8 @@ define(function (require, exports, module) {
fsm.when('* -> input', enterInputMode); fsm.when('* -> input', enterInputMode);
// when exited, commit or exit depends on the exit reason // when exited, commit or exit depends on the exit reason
fsm.when('input -> *', function (exit, enter, reason) { fsm.when('input -> *', function (exit, enter, reason) {
if (minder._status === 'readonly') return;
switch (reason) { switch (reason) {
case 'input-cancel': case 'input-cancel':
return exitInputMode(); return exitInputMode();
@ -43,11 +47,15 @@ define(function (require, exports, module) {
}); });
// lost focus to commit // lost focus to commit
receiver.onblur(function (e) { receiver.onblur(function (e) {
if (minder._status === 'readonly') return;
if (fsm.state() == 'input') { if (fsm.state() == 'input') {
fsm.jump('normal', 'input-commit'); fsm.jump('normal', 'input-commit');
} }
}); });
minder.on('beforemousedown', function () { minder.on('beforemousedown', function () {
if (minder._status === 'readonly') return;
if (fsm.state() == 'input') { if (fsm.state() == 'input') {
fsm.jump('normal', 'input-commit'); fsm.jump('normal', 'input-commit');
} }
@ -69,9 +77,14 @@ define(function (require, exports, module) {
minder.on('layoutallfinish viewchange viewchanged selectionchange', function (e) { minder.on('layoutallfinish viewchange viewchanged selectionchange', function (e) {
// viewchange event is too frequenced, lazy it // viewchange event is too frequenced, lazy it
if (e.type == 'viewchange' && fsm.state() != 'input') return; if (e.type == 'viewchange' && fsm.state() != 'input') return;
updatePosition();
if (minder.getStatus() !== 'readonly') {
updatePosition();
}
}); });
updatePosition(); if (minder.getStatus() !== 'readonly') {
updatePosition();
}
} }
// edit entrance in hotbox // edit entrance in hotbox
function setupHotbox() { function setupHotbox() {
@ -121,6 +134,8 @@ define(function (require, exports, module) {
* @Date 2015-12-2 * @Date 2015-12-2
*/ */
function enterInputMode() { function enterInputMode() {
if (minder._status === 'readonly') return;
var node = minder.getSelectedNode(); var node = minder.getSelectedNode();
if (node) { if (node) {
var fontSize = node.getData('font-size') || node.getStyle('font-size'); var fontSize = node.getData('font-size') || node.getStyle('font-size');
@ -141,6 +156,13 @@ define(function (require, exports, module) {
* @Date: 2015.9.16 * @Date: 2015.9.16
*/ */
function commitInputText(textNodes) { function commitInputText(textNodes) {
if (minder._status === 'readonly') return;
var node = minder.getSelectedNode();
if (!node) {
return;
}
var text = ''; var text = '';
var TAB_CHAR = '\t', var TAB_CHAR = '\t',
ENTER_CHAR = '\n', ENTER_CHAR = '\n',
@ -282,6 +304,8 @@ define(function (require, exports, module) {
* @Date: 2015.9.16 * @Date: 2015.9.16
*/ */
function commitInputNode(node, text) { function commitInputNode(node, text) {
if (minder._status === 'readonly') return;
try { try {
minder.decodeData('text', text).then(function (json) { minder.decodeData('text', text).then(function (json) {
function importText(node, json, minder) { function importText(node, json, minder) {
@ -309,6 +333,8 @@ define(function (require, exports, module) {
} }
} }
function commitInputResult() { function commitInputResult() {
if (minder._status === 'readonly') return;
/** /**
* @Desc: * @Desc:
* *
@ -338,10 +364,14 @@ define(function (require, exports, module) {
} }
} }
function exitInputMode() { function exitInputMode() {
if (minder._status === 'readonly') return;
receiverElement.classList.remove('input'); receiverElement.classList.remove('input');
receiver.selectAll(); receiver.selectAll();
} }
function updatePosition() { function updatePosition() {
if (minder._status === 'readonly') return;
var planed = updatePosition; var planed = updatePosition;
var focusNode = minder.getSelectedNode(); var focusNode = minder.getSelectedNode();
if (!focusNode) return; if (!focusNode) return;

View File

@ -48,18 +48,26 @@ define(function (require, exports, module) {
var receiverElement = receiver.element; var receiverElement = receiver.element;
var hotbox = this.hotbox; var hotbox = this.hotbox;
var compositionLock = false; var compositionLock = false;
var that = this;
// normal -> * // normal -> *
receiver.listen('normal', function (e) { receiver.listen('normal', function (e) {
if (minder._status === 'readonly') return;
// 为了防止处理进入edit模式而丢失处理的首字母,此时receiver必须为enable // 为了防止处理进入edit模式而丢失处理的首字母,此时receiver必须为enable
receiver.enable(); receiver.enable();
// normal -> hotbox // normal -> hotbox
if (e.is('Space')) { if (e.is('Space')) {
// 非编辑模式
if (that.minder.getStatus() === 'readonly') {
return;
}
e.preventDefault(); e.preventDefault();
// safari下Space触发hotbox,然而这时Space已在receiver上留下作案痕迹,因此抹掉 // safari下Space触发hotbox,然而这时Space已在receiver上留下作案痕迹,因此抹掉
if (kity.Browser.safari) { if (kity.Browser.safari) {
receiverElement.innerHTML = ''; receiverElement.innerHTML = '';
} }
return fsm.jump('hotbox', 'space-trigger'); return fsm.jump('hotbox', 'space-trigger');
} }
@ -91,6 +99,8 @@ define(function (require, exports, module) {
// hotbox -> normal // hotbox -> normal
receiver.listen('hotbox', function (e) { receiver.listen('hotbox', function (e) {
if (minder._status === 'readonly') return;
receiver.disable(); receiver.disable();
e.preventDefault(); e.preventDefault();
var handleResult = hotbox.dispatch(e); var handleResult = hotbox.dispatch(e);
@ -101,6 +111,8 @@ define(function (require, exports, module) {
// input => normal // input => normal
receiver.listen('input', function (e) { receiver.listen('input', function (e) {
if (minder._status === 'readonly') return;
receiver.enable(); receiver.enable();
if (e.type == 'keydown') { if (e.type == 'keydown') {
if (e.is('Enter')) { if (e.is('Enter')) {
@ -138,6 +150,8 @@ define(function (require, exports, module) {
container.addEventListener( container.addEventListener(
'mousedown', 'mousedown',
function (e) { function (e) {
if (minder._status === 'readonly') return;
if (e.button == MOUSE_RB) { if (e.button == MOUSE_RB) {
e.preventDefault(); e.preventDefault();
} }
@ -155,6 +169,8 @@ define(function (require, exports, module) {
container.addEventListener( container.addEventListener(
'mousewheel', 'mousewheel',
function (e) { function (e) {
if (minder._status === 'readonly') return;
if (fsm.state() == 'hotbox') { if (fsm.state() == 'hotbox') {
hotbox.active(Hotbox.STATE_IDLE); hotbox.active(Hotbox.STATE_IDLE);
fsm.jump('normal', 'mousemove-blur'); fsm.jump('normal', 'mousemove-blur');
@ -164,12 +180,14 @@ define(function (require, exports, module) {
); );
container.addEventListener('contextmenu', function (e) { container.addEventListener('contextmenu', function (e) {
if (minder._status === 'readonly') return;
e.preventDefault(); e.preventDefault();
}); });
container.addEventListener( container.addEventListener(
'mouseup', 'mouseup',
function (e) { function (e) {
if (minder._status === 'readonly') return;
if (fsm.state() != 'normal') { if (fsm.state() != 'normal') {
return; return;
} }
@ -186,6 +204,7 @@ define(function (require, exports, module) {
// 阻止热盒事件冒泡,在热盒正确执行前导致热盒关闭 // 阻止热盒事件冒泡,在热盒正确执行前导致热盒关闭
hotbox.$element.addEventListener('mousedown', function (e) { hotbox.$element.addEventListener('mousedown', function (e) {
if (minder._status === 'readonly') return;
e.stopPropagation(); e.stopPropagation();
}); });
} }

View File

@ -69,7 +69,7 @@ export const Callout = Node.create({
addInputRules() { addInputRules() {
return [ return [
wrappingInputRule({ wrappingInputRule({
find: /^\$callout $/, find: /^\$callout\$$/,
type: this.type, type: this.type,
getAttributes: (match) => { getAttributes: (match) => {
return { type: match[1] }; return { type: match[1] };

View File

@ -268,7 +268,7 @@ export const Columns = Node.create({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule({ nodeInputRule({
find: /^\$columns $/, find: /^\$columns\$$/,
type: this.type, type: this.type,
getAttributes: () => { getAttributes: () => {
return { type: 'left-right', columns: 2 }; return { type: 'left-right', columns: 2 };

View File

@ -78,7 +78,7 @@ export const Countdown = Node.create({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule({ nodeInputRule({
find: /^\$countdown $/, find: /^\$countdown\$$/,
type: this.type, type: this.type,
getAttributes: () => { getAttributes: () => {
return { width: '100%' }; return { width: '100%' };

View File

@ -103,7 +103,7 @@ export const Excalidraw = Node.create({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule({ nodeInputRule({
find: /^\$excalidraw $/, find: /^\$excalidraw\$$/,
type: this.type, type: this.type,
getAttributes: () => { getAttributes: () => {
return { width: '100%' }; return { width: '100%' };

View File

@ -12,6 +12,11 @@ export interface IFlowAttrs {
createUser?: IUser['id']; createUser?: IUser['id'];
} }
interface IFlowOptions {
HTMLAttributes: Record<string, any>;
getCreateUserId: () => string | number;
}
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
flow: { flow: {
@ -20,7 +25,7 @@ declare module '@tiptap/core' {
} }
} }
export const Flow = Node.create({ export const Flow = Node.create<IFlowOptions>({
name: 'flow', name: 'flow',
group: 'block', group: 'block',
selectable: true, selectable: true,
@ -55,6 +60,7 @@ export const Flow = Node.create({
HTMLAttributes: { HTMLAttributes: {
class: 'flow', class: 'flow',
}, },
getCreateUserId: () => null,
}; };
}, },
@ -102,10 +108,10 @@ export const Flow = Node.create({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule({ nodeInputRule({
find: /^\$flow $/, find: /^\$flow\$$/,
type: this.type, type: this.type,
getAttributes: () => { getAttributes: () => {
return { width: '100%' }; return { width: '100%', defaultShowPicker: true, createUser: this.options.getCreateUserId() };
}, },
}), }),
]; ];

View File

@ -98,7 +98,7 @@ export const Iframe = Node.create({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule({ nodeInputRule({
find: /^\$iframe $/, find: /^\$iframe\$$/,
type: this.type, type: this.type,
getAttributes: () => { getAttributes: () => {
return { width: '100%' }; return { width: '100%' };

View File

@ -10,6 +10,11 @@ export type IKatexAttrs = {
createUser?: IUser['id']; createUser?: IUser['id'];
}; };
interface IKatexOptions {
HTMLAttributes: Record<string, any>;
getCreateUserId: () => string | number;
}
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
katex: { katex: {
@ -18,7 +23,7 @@ declare module '@tiptap/core' {
} }
} }
export const Katex = Node.create({ export const Katex = Node.create<IKatexOptions>({
name: 'katex', name: 'katex',
group: 'block', group: 'block',
selectable: true, selectable: true,
@ -30,6 +35,7 @@ export const Katex = Node.create({
HTMLAttributes: { HTMLAttributes: {
class: 'katex', class: 'katex',
}, },
getCreateUserId: () => null,
}; };
}, },
@ -72,8 +78,11 @@ export const Katex = Node.create({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule({ nodeInputRule({
find: /^\$katex $/, find: /^\$katex\$$/,
type: this.type, type: this.type,
getAttributes: () => {
return { defaultShowPicker: true, createUser: this.options.getCreateUserId() };
},
}), }),
]; ];
}, },

View File

@ -22,6 +22,11 @@ export interface IMindAttrs {
zoom?: number; zoom?: number;
} }
interface IMindOptions {
HTMLAttributes: Record<string, any>;
getCreateUserId: () => string | number;
}
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
mind: { mind: {
@ -30,7 +35,7 @@ declare module '@tiptap/core' {
} }
} }
export const Mind = Node.create({ export const Mind = Node.create<IMindOptions>({
name: 'mind', name: 'mind',
group: 'block', group: 'block',
selectable: true, selectable: true,
@ -66,6 +71,7 @@ export const Mind = Node.create({
HTMLAttributes: { HTMLAttributes: {
class: 'mind', class: 'mind',
}, },
getCreateUserId: () => null,
}; };
}, },
@ -113,10 +119,10 @@ export const Mind = Node.create({
addInputRules() { addInputRules() {
return [ return [
nodeInputRule({ nodeInputRule({
find: /^\$mind $/, find: /^\$mind\$$/,
type: this.type, type: this.type,
getAttributes: () => { getAttributes: () => {
return { width: '100%' }; return { width: '100%', defaultShowPicker: true, createUser: this.options.getCreateUserId() };
}, },
}), }),
]; ];

View File

@ -1,3 +1,109 @@
import BuiltInPlaceholder from '@tiptap/extension-placeholder'; import { Editor, Extension } from '@tiptap/core';
import { Node as ProsemirrorNode } from 'prosemirror-model';
import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
export const Placeholder = BuiltInPlaceholder; export interface PlaceholderOptions {
emptyEditorClass: string;
emptyNodeClass: string;
placeholder: ((PlaceholderProps: { editor: Editor; node: ProsemirrorNode; pos: number }) => string) | string;
showOnlyWhenEditable: boolean;
showOnlyCurrent: boolean;
includeChildren: boolean;
}
export const Placeholder = Extension.create<PlaceholderOptions>({
name: 'placeholder',
addOptions() {
return {
emptyEditorClass: 'is-editor-empty',
emptyNodeClass: 'is-empty',
placeholder: 'Write something …',
showOnlyWhenEditable: true,
showOnlyCurrent: true,
includeChildren: false,
};
},
addStorage() {
return new Map();
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
decorations: ({ doc, selection }) => {
const active = this.editor.isEditable || !this.options.showOnlyWhenEditable;
const { anchor } = selection;
const decorations: Decoration[] = [];
if (!active) {
return;
}
doc.descendants((node, pos) => {
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
const isEmpty = !node.isLeaf && !node.childCount;
if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
const classes = [this.options.emptyNodeClass];
if (this.editor.isEmpty) {
classes.push(this.options.emptyEditorClass);
}
const start = pos;
const end = pos + node.nodeSize;
let placeholder = '';
if (this.editor.isEditable) {
const key = `${start}-${end}`;
if (!this.editor.storage[this.name].has(key)) {
this.editor.storage[this.name].set(
key,
typeof this.options.placeholder === 'function'
? this.options.placeholder({
editor: this.editor,
node,
pos,
})
: this.options.placeholder
);
}
placeholder = this.editor.storage[this.name].get(key);
setTimeout(() => {
this.editor.storage[this.name].delete(key);
}, 500);
} else {
placeholder =
typeof this.options.placeholder === 'function'
? this.options.placeholder({
editor: this.editor,
node,
pos,
})
: this.options.placeholder;
}
const decoration = Decoration.node(start, end, {
'class': classes.join(' '),
'data-placeholder': placeholder,
});
decorations.push(decoration);
}
return this.options.includeChildren;
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});

View File

@ -8,7 +8,7 @@ import { insertMenuLRUCache, QUICK_INSERT_COMMANDS, transformToCommands } from '
import { MenuList } from 'tiptap/core/wrappers/menu-list'; import { MenuList } from 'tiptap/core/wrappers/menu-list';
export const QuickInsertPluginKey = new PluginKey('quickInsert'); export const QuickInsertPluginKey = new PluginKey('quickInsert');
const extensionName = 'quickInsert' const extensionName = 'quickInsert';
export const QuickInsert = Node.create({ export const QuickInsert = Node.create({
name: extensionName, name: extensionName,
@ -63,8 +63,8 @@ export const QuickInsert = Node.create({
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
} },
} };
}, },
}).configure({ }).configure({
suggestion: { suggestion: {

View File

@ -32,7 +32,6 @@ export const ExcalidrawSettingModal: React.FC<IProps> = ({ editor }) => {
); );
const renderExcalidraw = useCallback((app) => { const renderExcalidraw = useCallback((app) => {
console.log('render', app);
setTimeout(() => { setTimeout(() => {
app.refresh(); app.refresh();
}); });
@ -54,7 +53,14 @@ export const ExcalidrawSettingModal: React.FC<IProps> = ({ editor }) => {
return; return;
} }
/**
* FIXME: 绘图更新后会滚动 dom hack
*/
const currentScrollTop = document.querySelector('main#js-tocs-container')?.scrollTop;
editor.chain().focus().setExcalidraw({ data }).run(); editor.chain().focus().setExcalidraw({ data }).run();
setTimeout(() => {
document.querySelector('main#js-tocs-container').scrollTop = currentScrollTop;
});
toggleVisible(false); toggleVisible(false);
}, [Excalidraw, editor, data, toggleVisible]); }, [Excalidraw, editor, data, toggleVisible]);

View File

@ -43,7 +43,7 @@ export const KatexBubbleMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
setTimeout(() => ref.current?.focus(), 100); setTimeout(() => ref.current?.focus(), 200);
} }
}, [visible]); }, [visible]);

View File

@ -1,3 +1,36 @@
const getProgressTitle = (index) => {
switch (index) {
case 0:
return '移除进度';
case 1:
return '未开始';
case 9:
return '全部完成';
default:
return '完成' + (index - 1) + '/8';
}
};
export const PROGRESSES = Array.from({ length: 10 }, (_, i) => {
return {
text: getProgressTitle(i),
value: i,
};
});
export const PRIORITIES = [
{
text: '移除优先级',
value: 0,
},
...Array.from({ length: 9 }, (_, i) => {
return {
text: `P${i + 1}`,
value: i + 1,
};
}),
];
export const TEMPLATES = [ export const TEMPLATES = [
{ {
label: '经典', label: '经典',

View File

@ -46,7 +46,14 @@ export const MindSettingModal: React.FC<IProps> = ({ editor }) => {
return; return;
} }
const data = mind.exportJson(); const data = mind.exportJson();
/**
* FIXME: 百度脑图更新后会滚动 dom hack
*/
const currentScrollTop = document.querySelector('main#js-tocs-container')?.scrollTop;
editor.chain().focus().setMind({ data }).run(); editor.chain().focus().setMind({ data }).run();
setTimeout(() => {
document.querySelector('main#js-tocs-container').scrollTop = currentScrollTop;
});
toggleVisible(false); toggleVisible(false);
}, [editor, toggleVisible, mind]); }, [editor, toggleVisible, mind]);
@ -65,6 +72,12 @@ export const MindSettingModal: React.FC<IProps> = ({ editor }) => {
}; };
}, [editor, toggleVisible]); }, [editor, toggleVisible]);
useEffect(() => {
if (!visible && mind) {
mind.destroy();
}
}, [visible, mind]);
return ( return (
<Modal <Modal
centered centered
@ -75,6 +88,7 @@ export const MindSettingModal: React.FC<IProps> = ({ editor }) => {
onOk={save} onOk={save}
okText="保存" okText="保存"
cancelText="退出" cancelText="退出"
motion={false}
> >
<div <div
style={{ style={{

View File

@ -0,0 +1,39 @@
import { IconMark } from '@douyinfe/semi-icons';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { ColorPicker } from 'components/color-picker';
export const BgColor = ({ bgColor, selectedNode, setBackgroundColor }) => {
return (
<ColorPicker
onSetColor={(color) => {
setBackgroundColor(color);
}}
>
<Tooltip content="背景色" zIndex={10000}>
<Button
disabled={!selectedNode}
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>
);
};

View File

@ -0,0 +1,39 @@
import { IconFont } from '@douyinfe/semi-icons';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { ColorPicker } from 'components/color-picker';
export const FontColor = ({ selectedNode, setFontColor, textColor }) => {
return (
<ColorPicker
onSetColor={(color) => {
setFontColor(color);
}}
>
<Tooltip content="文本色" zIndex={10000}>
<Button
disabled={!selectedNode}
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>
);
};

View File

@ -0,0 +1,33 @@
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Button, Descriptions, Popover } from '@douyinfe/semi-ui';
import styles from './index.module.scss';
const HELP_MESSAGE = [
{ key: '新增同级节点', value: 'Enter 键' },
{ key: '新增子节点', value: 'Tab 键' },
{ key: '编辑节点文字', value: '双击节点' },
{ key: '编辑节点菜单', value: '在节点右键' },
];
const HELP_MESSAGE_STYLE = {
width: '200px',
};
export const Help = () => {
return (
<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>
);
};

View File

@ -1,4 +1,4 @@
import { IconFile } from '@douyinfe/semi-icons'; import { IconImage } from '@douyinfe/semi-icons';
import { Button, Dropdown, Form, Tooltip } from '@douyinfe/semi-ui'; import { Button, Dropdown, Form, Tooltip } from '@douyinfe/semi-ui';
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'; import { FormApi } from '@douyinfe/semi-ui/lib/es/form';
import { Upload } from 'components/upload'; import { Upload } from 'components/upload';
@ -48,7 +48,7 @@ export const Image = ({ disabled, image, setImage }) => {
> >
<span style={{ display: 'inline-block' }}> <span style={{ display: 'inline-block' }}>
<Tooltip content="设置图片" zIndex={10000}> <Tooltip content="设置图片" zIndex={10000}>
<Button disabled={disabled} type="tertiary" theme={image ? 'light' : 'borderless'} icon={<IconFile />} /> <Button disabled={disabled} type="tertiary" theme={image ? 'light' : 'borderless'} icon={<IconImage />} />
</Tooltip> </Tooltip>
</span> </span>
</Dropdown> </Dropdown>

View File

@ -1,108 +1,188 @@
import { IconBold, IconFont, IconHelpCircle, IconMark } from '@douyinfe/semi-icons'; import { IconBold, IconRedo, IconUndo } from '@douyinfe/semi-icons';
import { Button, Descriptions, Popover, Space, Tooltip, Typography } from '@douyinfe/semi-ui'; import { Button, Space, Tooltip } from '@douyinfe/semi-ui';
import cls from 'classnames'; import { Divider } from 'components/divider';
import { ColorPicker } from 'components/color-picker'; import { IconMindCenter } from 'components/icons';
import { IconDrawBoard, IconMindCenter, IconStructure } from 'components/icons';
import { IconZoomIn, IconZoomOut } from 'components/icons'; import { IconZoomIn, IconZoomOut } from 'components/icons';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { clamp } from 'tiptap/prose-utils';
import { MAX_ZOOM, MIN_ZOOM, TEMPLATES, THEMES, ZOOM_STEP } from '../constant'; import { BgColor } from './bgcolor';
import { FontColor } from './font-color';
import { Help } from './help';
import { Image } from './image'; import { Image } from './image';
import styles from './index.module.scss';
import { Link } from './link'; import { Link } from './link';
import { Priority } from './priority';
const { Text } = Typography; import { Progress } from './progress';
import { Template } from './template';
const HELP_MESSAGE = [ import { Theme } from './theme';
{ key: '新增同级节点', value: 'Enter 键' },
{ key: '新增子节点', value: 'Tab 键' },
{ key: '编辑节点文字', value: '双击节点' },
{ key: '编辑节点菜单', value: '在节点右键' },
];
const HELP_MESSAGE_STYLE = {
width: '200px',
};
export const Toolbar = ({ mind }) => { export const Toolbar = ({ mind }) => {
const [template, setTemplateState] = useState(''); const [node, setNode] = useState(null); // 当前选择节点
const [theme, setThemeState] = useState('');
const [node, setNode] = useState(null); const [hasUndo, toggleHasUndo] = useToggle(false);
const [hasRedo, toggleHasRedo] = useToggle(false);
const [isBold, toggleIsBold] = useToggle(false); const [isBold, toggleIsBold] = useToggle(false);
const [textColor, setTextColor] = useState(''); const [textColor, setTextColor] = useState('');
const [bgColor, setBgColor] = useState(''); const [bgColor, setBgColor] = useState('');
const [link, setLink] = useState(''); const [link, setLink] = useState('');
const [image, setImage] = useState(''); const [image, setImage] = useState('');
const setTemplate = useCallback( const [template, setTemplateState] = useState('');
(template) => { const [theme, setThemeState] = useState('');
mind.execCommand('template', template);
},
[mind]
);
const setTheme = useCallback( /**
(theme) => { *
mind.execCommand('theme', theme); */
}, const undo = useCallback(() => {
[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; if (!mind) return;
mind.execCommand('camera'); if (mind.editor.history.hasUndo()) {
mind.editor.history.undo();
}
}, [mind]); }, [mind]);
/**
*
*/
const redo = useCallback(() => {
if (!mind) return;
if (mind.editor.history.hasRedo()) {
mind.editor.history.redo();
}
}, [mind]);
/**
*
*/
const toggleBold = useCallback(() => { const toggleBold = useCallback(() => {
if (!mind) return;
mind.execCommand('Bold'); mind.execCommand('Bold');
}, [mind]); }, [mind]);
/**
*
*/
const setFontColor = useCallback( const setFontColor = useCallback(
(color) => { (color) => {
if (!mind) return;
mind.execCommand('ForeColor', color); mind.execCommand('ForeColor', color);
}, },
[mind] [mind]
); );
/**
*
*/
const setBackgroundColor = useCallback( const setBackgroundColor = useCallback(
(color) => { (color) => {
if (!mind) return;
mind.execCommand('Background', color); mind.execCommand('Background', color);
}, },
[mind] [mind]
); );
/**
*
*/
const setHyperLink = useCallback( const setHyperLink = useCallback(
(url) => { (url) => {
if (!mind) return;
mind.execCommand('HyperLink', url); mind.execCommand('HyperLink', url);
}, },
[mind] [mind]
); );
/**
*
*/
const insertImage = useCallback( const insertImage = useCallback(
(url) => { (url) => {
if (!mind) return;
mind.execCommand('Image', url); mind.execCommand('Image', url);
}, },
[mind] [mind]
); );
/**
*
*/
const setProgress = useCallback(
(value) => () => {
if (!mind) return;
const node = mind.getSelectedNode();
if (!node) return;
mind.execCommand('progress', value);
},
[mind]
);
/**
*
*/
const setPriority = useCallback(
(value) => () => {
if (!mind) return;
const node = mind.getSelectedNode();
if (!node) return;
mind.execCommand('priority', value);
},
[mind]
);
/**
*
*/
const setTemplate = useCallback(
(template) => {
if (!mind) return;
mind.execCommand('template', template);
},
[mind]
);
/**
*
*/
const setTheme = useCallback(
(theme) => {
if (!mind) return;
mind.execCommand('theme', theme);
},
[mind]
);
/**
*
*/
const setZoom = useCallback(
(type: 'minus' | 'plus') => {
return () => {
if (!mind) return;
mind.execCommand(type === 'minus' ? 'zoomOut' : 'zoomIn');
};
},
[mind]
);
/**
*
*/
const setCenter = useCallback(() => {
if (!mind) return;
mind.execCommand('camera', mind.getRoot(), 600);
}, [mind]);
useEffect(() => { useEffect(() => {
if (!mind) return; if (!mind) return;
@ -126,6 +206,9 @@ export const Toolbar = ({ mind }) => {
setNode(null); setNode(null);
} }
toggleHasUndo(mind.editor.history.hasUndo());
toggleHasRedo(mind.editor.history.hasRedo());
setTemplateState(mind.queryCommandValue('Template')); setTemplateState(mind.queryCommandValue('Template'));
setThemeState(mind.queryCommandValue('Theme')); setThemeState(mind.queryCommandValue('Theme'));
toggleIsBold(isBold); toggleIsBold(isBold);
@ -140,68 +223,53 @@ export const Toolbar = ({ mind }) => {
return () => { return () => {
mind.off('interactchange', handler); mind.off('interactchange', handler);
}; };
}, [mind, toggleIsBold, setBackgroundColor]); }, [mind, toggleHasUndo, toggleHasRedo, toggleIsBold, setBackgroundColor]);
return ( return (
<Space> <Space>
<Popover <Tooltip content="撤销">
zIndex={10000} <Button
spacing={10} onClick={undo}
style={{ padding: '0 12px 12px', overflow: 'hidden' }} icon={<IconUndo />}
position="bottomLeft" disabled={!hasUndo}
content={ theme={hasUndo ? 'light' : 'borderless'}
<section className={styles.sectionWrap}> type="tertiary"
<Text type="secondary"></Text> />
<div> </Tooltip>
<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 <Tooltip content="重做">
zIndex={10000} <Button
spacing={10} onClick={redo}
style={{ padding: '0 12px 12px', overflow: 'hidden' }} icon={<IconRedo />}
position="bottomLeft" disabled={!hasRedo}
content={ theme={hasRedo ? 'light' : 'borderless'}
<section className={styles.sectionWrap}> type="tertiary"
<Text type="secondary"></Text> />
<div> </Tooltip>
<ul>
{THEMES.map((item) => { <Divider />
return (
<li <Tooltip content="加粗" zIndex={10000}>
key={item.label} <Button
className={cls(theme === item.value && styles.active)} disabled={!node}
style={item.style || {}} theme={isBold ? 'light' : 'borderless'}
onClick={() => setTheme(item.value)} type="tertiary"
> onClick={toggleBold}
{item.label} icon={<IconBold />}
</li> />
); </Tooltip>
})}
</ul> <FontColor selectedNode={node} textColor={textColor} setFontColor={setFontColor} />
</div> <BgColor selectedNode={node} bgColor={bgColor} setBackgroundColor={setBackgroundColor} />
</section> <Link disabled={!node} link={link} setLink={setHyperLink} />
} <Image disabled={!node} image={image} setImage={insertImage} />
>
<Button icon={<IconDrawBoard />} type="tertiary" theme="borderless" size="small" /> <Divider />
</Popover>
<Progress selectedNode={node} setProgress={setProgress} />
<Priority selectedNode={node} setPriority={setPriority} />
<Divider />
<Tooltip content="居中"> <Tooltip content="居中">
<Button <Button
@ -233,97 +301,14 @@ export const Toolbar = ({ mind }) => {
/> />
</Tooltip> </Tooltip>
<Tooltip content="加粗" zIndex={10000}> <Divider />
<Button
disabled={!node}
type="tertiary"
theme={isBold ? 'light' : 'borderless'}
onClick={toggleBold}
icon={<IconBold />}
/>
</Tooltip>
<ColorPicker <Template template={template} setTemplate={setTemplate} />
onSetColor={(color) => { <Theme theme={theme} setTheme={setTheme} />
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 <Divider />
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} /> <Help />
<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> </Space>
); );
}; };

View File

@ -0,0 +1,29 @@
import { IconClock } from '@douyinfe/semi-icons';
import { Button, Dropdown, Tooltip } from '@douyinfe/semi-ui';
import { PRIORITIES } from '../constant';
export const Priority = ({ selectedNode, setPriority }) => {
return (
<Dropdown
trigger="click"
render={
<Dropdown.Menu>
{PRIORITIES.map((item) => {
return (
<Dropdown.Item key={item.value} onClick={setPriority(item.value)}>
{item.text}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
<span>
<Tooltip content="优先级" zIndex={10000}>
<Button size="small" theme="borderless" type="tertiary" disabled={!selectedNode} icon={<IconClock />} />
</Tooltip>
</span>
</Dropdown>
);
};

View File

@ -0,0 +1,29 @@
import { IconDuration } from '@douyinfe/semi-icons';
import { Button, Dropdown, Tooltip } from '@douyinfe/semi-ui';
import { PROGRESSES } from '../constant';
export const Progress = ({ selectedNode, setProgress }) => {
return (
<Dropdown
trigger="click"
render={
<Dropdown.Menu>
{PROGRESSES.map((progress) => {
return (
<Dropdown.Item key={progress.value} onClick={setProgress(progress.value)}>
{progress.text}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
<span>
<Tooltip content="进度" zIndex={10000}>
<Button size="small" theme="borderless" type="tertiary" disabled={!selectedNode} icon={<IconDuration />} />
</Tooltip>
</span>
</Dropdown>
);
};

View File

@ -0,0 +1,41 @@
import { Button, Popover, Typography } from '@douyinfe/semi-ui';
import cls from 'classnames';
import { IconStructure } from 'components/icons';
import { TEMPLATES } from '../constant';
import styles from './index.module.scss';
const { Text } = Typography;
export const Template = ({ template, setTemplate }) => {
return (
<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>
);
};

View File

@ -0,0 +1,42 @@
import { Button, Popover, Typography } from '@douyinfe/semi-ui';
import cls from 'classnames';
import { IconDrawBoard } from 'components/icons';
import { THEMES } from '../constant';
import styles from './index.module.scss';
const { Text } = Typography;
export const Theme = ({ theme, setTheme }) => {
return (
<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>
);
};

View File

@ -3,7 +3,9 @@ import { NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames'; import cls from 'classnames';
import { IconMind } from 'components/icons'; import { IconMind } from 'components/icons';
import { Resizeable } from 'components/resizeable'; import { Resizeable } from 'components/resizeable';
import deepEqual from 'deep-equal';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import VisibilitySensor from 'react-visibility-sensor'; import VisibilitySensor from 'react-visibility-sensor';
import { Excalidraw } from 'tiptap/core/extensions/excalidraw'; import { Excalidraw } from 'tiptap/core/extensions/excalidraw';
@ -15,7 +17,7 @@ const { Text } = Typography;
const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' }; const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' };
export const ExcalidrawWrapper = ({ editor, node, updateAttributes }) => { export const _ExcalidrawWrapper = ({ editor, node, updateAttributes }) => {
const exportToSvgRef = useRef(null); const exportToSvgRef = useRef(null);
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
const isActive = editor.isActive(Excalidraw.name); const isActive = editor.isActive(Excalidraw.name);
@ -111,3 +113,11 @@ export const ExcalidrawWrapper = ({ editor, node, updateAttributes }) => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const ExcalidrawWrapper = React.memo(_ExcalidrawWrapper, (prevProps, nextProps) => {
if (deepEqual(prevProps.node.attrs, nextProps.node.attrs)) {
return true;
}
return false;
});

View File

@ -3,7 +3,9 @@ import { NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames'; import cls from 'classnames';
import { IconFlow, IconMindCenter, IconZoomIn, IconZoomOut } from 'components/icons'; import { IconFlow, IconMindCenter, IconZoomIn, IconZoomOut } from 'components/icons';
import { Resizeable } from 'components/resizeable'; import { Resizeable } from 'components/resizeable';
import deepEqual from 'deep-equal';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import VisibilitySensor from 'react-visibility-sensor'; import VisibilitySensor from 'react-visibility-sensor';
import { load, renderXml } from 'thirtypart/diagram'; import { load, renderXml } from 'thirtypart/diagram';
@ -15,7 +17,7 @@ import styles from './index.module.scss';
const { Text } = Typography; const { Text } = Typography;
const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' }; const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' };
export const FlowWrapper = ({ editor, node, updateAttributes }) => { export const _FlowWrapper = ({ editor, node, updateAttributes }) => {
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
const isActive = editor.isActive(Flow.name); const isActive = editor.isActive(Flow.name);
const { width: maxWidth } = getEditorContainerDOMSize(editor); const { width: maxWidth } = getEditorContainerDOMSize(editor);
@ -81,7 +83,7 @@ export const FlowWrapper = ({ editor, node, updateAttributes }) => {
const onViewportChange = useCallback( const onViewportChange = useCallback(
(visible) => { (visible) => {
if (visible) { if (visible && !$graph.current) {
toggleVisible(true); toggleVisible(true);
} }
}, },
@ -137,3 +139,11 @@ export const FlowWrapper = ({ editor, node, updateAttributes }) => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const FlowWrapper = React.memo(_FlowWrapper, (prevProps, nextProps) => {
if (deepEqual(prevProps.node.attrs, nextProps.node.attrs)) {
return true;
}
return false;
});

View File

@ -6,10 +6,9 @@ import { Resizeable } from 'components/resizeable';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import deepEqual from 'deep-equal'; import deepEqual from 'deep-equal';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import VisibilitySensor from 'react-visibility-sensor'; import VisibilitySensor from 'react-visibility-sensor';
import { load, renderMind } from 'thirtypart/kityminder'; import { load, renderMind } from 'thirtypart/kityminder';
import { Mind } from 'tiptap/core/extensions/mind';
import { MAX_ZOOM, MIN_ZOOM, ZOOM_STEP } from 'tiptap/core/menus/mind/constant'; import { MAX_ZOOM, MIN_ZOOM, ZOOM_STEP } from 'tiptap/core/menus/mind/constant';
import { clamp, getEditorContainerDOMSize } from 'tiptap/prose-utils'; import { clamp, getEditorContainerDOMSize } from 'tiptap/prose-utils';
@ -19,10 +18,9 @@ const { Text } = Typography;
const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' }; const INHERIT_SIZE_STYLE = { width: '100%', height: '100%', maxWidth: '100%' };
export const MindWrapper = ({ editor, node, updateAttributes }) => { export const _MindWrapper = ({ editor, node, updateAttributes }) => {
const $mind = useRef(null); const $mind = useRef(null);
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
const isActive = editor.isActive(Mind.name);
const { width: maxWidth } = getEditorContainerDOMSize(editor); const { width: maxWidth } = getEditorContainerDOMSize(editor);
const { data, width, height } = node.attrs; const { data, width, height } = node.attrs;
const [visible, toggleVisible] = useToggle(false); const [visible, toggleVisible] = useToggle(false);
@ -80,7 +78,7 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
const onViewportChange = useCallback( const onViewportChange = useCallback(
(visible) => { (visible) => {
if (visible) { if (visible && !$mind.current) {
toggleVisible(true); toggleVisible(true);
} }
}, },
@ -108,7 +106,7 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
}, [width, height, setCenter]); }, [width, height, setCenter]);
return ( return (
<NodeViewWrapper className={cls(styles.wrap, isActive && styles.isActive)}> <NodeViewWrapper className={cls(styles.wrap)}>
<VisibilitySensor onChange={onViewportChange}> <VisibilitySensor onChange={onViewportChange}>
<Resizeable isEditable={isEditable} width={width} height={height} maxWidth={maxWidth} onChangeEnd={onResize}> <Resizeable isEditable={isEditable} width={width} height={height} maxWidth={maxWidth} onChangeEnd={onResize}>
<div <div
@ -123,7 +121,7 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
{loading && <Spin spinning style={INHERIT_SIZE_STYLE}></Spin>} {loading && <Spin spinning style={INHERIT_SIZE_STYLE}></Spin>}
{!loading && !error && visible && ( {!loading && !error && (
<div style={{ height: '100%', maxHeight: '100%', overflow: 'hidden' }} ref={setMind}></div> <div style={{ height: '100%', maxHeight: '100%', overflow: 'hidden' }} ref={setMind}></div>
)} )}
@ -165,3 +163,11 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };
export const MindWrapper = React.memo(_MindWrapper, (prevProps, nextProps) => {
if (deepEqual(prevProps.node.attrs, nextProps.node.attrs)) {
return true;
}
return false;
});

View File

@ -4,7 +4,7 @@ import { NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames'; import cls from 'classnames';
import { useUser } from 'data/user'; import { useUser } from 'data/user';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './index.module.scss'; import styles from './index.module.scss';
@ -24,14 +24,15 @@ export const StatusWrapper = ({ editor, node, updateAttributes }) => {
const { user } = useUser(); const { user } = useUser();
const ref = useRef<HTMLInputElement>(); const ref = useRef<HTMLInputElement>();
const [visible, toggleVisible] = useToggle(false); const [visible, toggleVisible] = useToggle(false);
const [currentText, setCurrentText] = useState(text);
const content = useMemo( const content = useMemo(
() => ( () => (
<Tag className="render-wrapper" style={{ backgroundColor: bgcolor, border: `1px solid ${borderColor}` }}> <Tag className="render-wrapper" style={{ backgroundColor: bgcolor, border: `1px solid ${borderColor}` }}>
<span style={{ color: currentTextColor }}>{text || '点击设置状态'}</span> <span style={{ color: currentTextColor }}>{currentText || '点击设置状态'}</span>
</Tag> </Tag>
), ),
[bgcolor, borderColor, currentTextColor, text] [bgcolor, borderColor, currentTextColor, currentText]
); );
const onVisibleChange = useCallback( const onVisibleChange = useCallback(
@ -64,8 +65,10 @@ export const StatusWrapper = ({ editor, node, updateAttributes }) => {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
ref.current?.focus(); ref.current?.focus();
} else {
updateAttributes({ text: currentText });
} }
}, [visible]); }, [visible, updateAttributes, currentText]);
return ( return (
<NodeViewWrapper as="span" className={cls(styles.wrap, 'status')}> <NodeViewWrapper as="span" className={cls(styles.wrap, 'status')}>
@ -78,7 +81,7 @@ export const StatusWrapper = ({ editor, node, updateAttributes }) => {
content={ content={
<div style={{ width: 184, height: 65 }}> <div style={{ width: 184, height: 65 }}>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Input ref={ref} placeholder="输入状态" onChange={(v) => updateAttributes({ text: v })} /> <Input ref={ref} placeholder="输入状态" value={currentText} onChange={setCurrentText} />
</div> </div>
<Space> <Space>
{STATUS_COLORS.map((color) => { {STATUS_COLORS.map((color) => {

View File

@ -1,5 +1,5 @@
import { Toast } from '@douyinfe/semi-ui'; import { Toast } from '@douyinfe/semi-ui';
import scrollIntoView from 'scroll-into-view-if-needed'; import { safeJSONParse } from 'helpers/json';
// 自定义节点扩展 // 自定义节点扩展
import { Attachment } from 'tiptap/core/extensions/attachment'; import { Attachment } from 'tiptap/core/extensions/attachment';
import { BackgroundColor } from 'tiptap/core/extensions/background-color'; import { BackgroundColor } from 'tiptap/core/extensions/background-color';
@ -78,6 +78,16 @@ const DocumentWithTitle = Document.extend({
export { Document }; export { Document };
const placeholders = [
'输入 / 唤起更多',
'使用 markdown 语法进行输入',
'输入 @ 来提及他人',
'输入 : 来插入表情',
'你知道吗?输入 $katex 然后在输入一个 $ 就可以快速插入数学公式,其他节点操作类似哦',
];
const getCreateUserId = () => safeJSONParse(window.localStorage.getItem('user')).id;
export const CollaborationKit = [ export const CollaborationKit = [
Paragraph, Paragraph,
Placeholder.configure({ Placeholder.configure({
@ -88,7 +98,7 @@ export const CollaborationKit = [
if (!editor.isEditable) return; if (!editor.isEditable) return;
return '输入 / 唤起更多'; return placeholders[~~(Math.random() * placeholders.length)];
}, },
showOnlyCurrent: false, showOnlyCurrent: false,
showOnlyWhenEditable: false, showOnlyWhenEditable: false,
@ -152,11 +162,17 @@ export const CollaborationKit = [
DocumentChildren, DocumentChildren,
DocumentReference, DocumentReference,
Emoji, Emoji,
Flow, Flow.configure({
getCreateUserId,
}),
Iframe, Iframe,
Katex, Katex.configure({
getCreateUserId,
}),
Mention, Mention,
Mind, Mind.configure({
getCreateUserId,
}),
QuickInsert, QuickInsert,
SearchNReplace, SearchNReplace,
Status, Status,