tiptap: improve copy paste

pull/31/head
fantasticit 2022-04-29 15:55:32 +08:00
parent a066cea4a7
commit cc3efef3f1
26 changed files with 558 additions and 58 deletions

View File

@ -56,7 +56,6 @@
"@tiptap/suggestion": "^2.0.0-beta.90", "@tiptap/suggestion": "^2.0.0-beta.90",
"axios": "^0.25.0", "axios": "^0.25.0",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.1",
"deep-equal": "^2.0.5", "deep-equal": "^2.0.5",
"dompurify": "^2.3.5", "dompurify": "^2.3.5",
"interactjs": "^1.10.11", "interactjs": "^1.10.11",
@ -85,6 +84,7 @@
"scroll-into-view-if-needed": "^2.2.29", "scroll-into-view-if-needed": "^2.2.29",
"swr": "^1.2.0", "swr": "^1.2.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"toggle-selection": "^1.0.6",
"viewerjs": "^1.10.4", "viewerjs": "^1.10.4",
"y-indexeddb": "^9.0.7", "y-indexeddb": "^9.0.7",
"y-prosemirror": "^1.0.14", "y-prosemirror": "^1.0.14",

View File

@ -0,0 +1,147 @@
'use strict';
var deselectCurrent = require('toggle-selection');
var clipboardToIE11Formatting = {
'text/plain': 'Text',
'text/html': 'Url',
'default': 'Text',
};
var defaultMessage = 'Copy to clipboard: #{key}, Enter';
function format(message) {
var copyKey = (/mac os x/i.test(navigator.userAgent) ? '⌘' : 'Ctrl') + '+C';
return message.replace(/#{\s*key\s*}/g, copyKey);
}
function copy(text, options) {
var debug,
message,
reselectPrevious,
range,
selection,
mark,
success = false;
if (!options) {
options = {};
}
debug = options.debug || false;
try {
reselectPrevious = deselectCurrent();
range = document.createRange();
selection = document.getSelection();
mark = document.createElement('span');
mark.textContent = text;
// reset user styles for span element
mark.style.all = 'unset';
// prevents scrolling to the end of the page
mark.style.position = 'fixed';
mark.style.top = 0;
mark.style.clip = 'rect(0, 0, 0, 0)';
// used to preserve spaces and line breaks
mark.style.whiteSpace = 'pre';
// do not inherit user-select (it may be `none`)
mark.style.webkitUserSelect = 'text';
mark.style.MozUserSelect = 'text';
mark.style.msUserSelect = 'text';
mark.style.userSelect = 'text';
mark.addEventListener('copy', function (e) {
e.stopPropagation();
if (options.format) {
e.preventDefault();
if (typeof e.clipboardData === 'undefined') {
// IE 11
debug && console.warn('unable to use e.clipboardData');
debug && console.warn('trying IE specific stuff');
window.clipboardData.clearData();
var format = clipboardToIE11Formatting[options.format] || clipboardToIE11Formatting['default'];
if (Array.isArray(text)) {
text.forEach((item) => {
if (typeof item === 'string') {
window.clipboardData.setData(item, item);
} else {
window.clipboardData.setData(item.format || format, item.text || item);
}
});
} else {
window.clipboardData.setData(format, text);
}
} else {
// all other browsers
e.clipboardData.clearData();
if (Array.isArray(text)) {
text.forEach((item) => {
if (typeof item === 'string') {
e.clipboardData.setData(item, item);
} else {
e.clipboardData.setData(item.format || format, item.text || item);
}
});
console.log(e.clipboardData);
} else {
e.clipboardData.setData(format, text);
}
}
}
if (options.onCopy) {
e.preventDefault();
options.onCopy(e.clipboardData);
}
});
document.body.appendChild(mark);
range.selectNodeContents(mark);
selection.addRange(range);
var successful = document.execCommand('copy');
if (!successful) {
throw new Error('copy command was unsuccessful');
}
success = true;
} catch (err) {
debug && console.error('unable to copy using execCommand: ', err);
debug && console.warn('trying IE specific stuff');
try {
if (Array.isArray(text)) {
text.forEach((item) => {
if (typeof item === 'string') {
window.clipboardData.setData(item, item);
} else {
window.clipboardData.setData(item.format || format, item.text || item);
}
});
} else {
window.clipboardData.setData(format, text);
}
console.log(window.clipboardData, '1');
options.onCopy && options.onCopy(window.clipboardData);
success = true;
} catch (err) {
debug && console.error('unable to copy using clipboardData: ', err);
debug && console.error('falling back to prompt');
message = format('message' in options ? options.message : defaultMessage);
window.prompt(message, text);
}
} finally {
if (selection) {
if (typeof selection.removeRange == 'function') {
selection.removeRange(range);
} else {
selection.removeAllRanges();
}
}
if (mark) {
document.body.removeChild(mark);
}
reselectPrevious();
}
return success;
}
module.exports = copy;

View File

@ -1,7 +1,16 @@
import _copy from 'copy-to-clipboard'; import _copy from './copy-to-clipboard';
import { Toast } from '@douyinfe/semi-ui'; import { Toast } from '@douyinfe/semi-ui';
export function copy(text: string, msg = '复制成功') { interface Options {
Toast.success(msg); debug?: boolean;
return _copy(text); message?: string;
format?: string; // MIME type
onCopy?: (clipboardData: object) => void;
}
export function copy(text: string | { text: string; format: string }[], options?: Options) {
options = options || {};
options.onCopy = options.onCopy || (() => Toast.success(options.message || '复制成功'));
options.format = options.format || 'text/plain';
return _copy(text, options);
} }

View File

@ -8,6 +8,8 @@ import {
markdownToProsemirror, markdownToProsemirror,
prosemirrorToMarkdown, prosemirrorToMarkdown,
} from 'tiptap/markdown/markdown-to-prosemirror'; } from 'tiptap/markdown/markdown-to-prosemirror';
import { copyNode } from 'tiptap/prose-utils';
import { safeJSONParse } from 'helpers/json';
const isPureText = (content): boolean => { const isPureText = (content): boolean => {
if (!content) return false; if (!content) return false;
@ -43,7 +45,6 @@ export const Paste = Extension.create({
if (!event.clipboardData) return false; if (!event.clipboardData) return false;
const files = Array.from(event.clipboardData.files); const files = Array.from(event.clipboardData.files);
if (files.length) { if (files.length) {
event.preventDefault(); event.preventDefault();
files.forEach((file) => { files.forEach((file) => {
@ -55,6 +56,16 @@ export const Paste = Extension.create({
const text = event.clipboardData.getData('text/plain'); const text = event.clipboardData.getData('text/plain');
const html = event.clipboardData.getData('text/html'); const html = event.clipboardData.getData('text/html');
const vscode = event.clipboardData.getData('vscode-editor-data'); const vscode = event.clipboardData.getData('vscode-editor-data');
const node = event.clipboardData.getData('text/node');
const markdownText = event.clipboardData.getData('text/markdown');
if (node) {
const doc = safeJSONParse(node);
let tr = view.state.tr;
const selection = tr.selection;
view.dispatch(tr.insert(selection.from - 1, view.state.schema.nodeFromJSON(doc)).scrollIntoView());
return true;
}
// 粘贴代码 // 粘贴代码
if (isInCode(view.state)) { if (isInCode(view.state)) {
@ -80,14 +91,14 @@ export const Paste = Extension.create({
} }
// 处理 markdown // 处理 markdown
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { if (markdownText || isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
event.preventDefault(); event.preventDefault();
const firstNode = view.props.state.doc.content.firstChild; const firstNode = view.props.state.doc.content.firstChild;
const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0; const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0;
const schema = view.props.state.schema; const schema = view.props.state.schema;
const doc = markdownToProsemirror({ const doc = markdownToProsemirror({
schema, schema,
content: normalizePastedMarkdown(text), content: normalizePastedMarkdown(markdownText || text),
hasTitle, hasTitle,
}); });
let tr = view.state.tr; let tr = view.state.tr;
@ -130,8 +141,41 @@ export const Paste = Extension.create({
return false; return false;
}, },
handleKeyDown(view, event) {
/**
* Command + C
* Ctrl + C
*/
if ((event.ctrlKey || event.metaKey) && event.keyCode == 67) {
const { state } = view;
const $pos = state.selection.$anchor;
// @ts-ignore
const currentNode = state.selection.node;
let targetNode = null;
if (currentNode) {
targetNode = currentNode;
} else {
if ($pos.depth) {
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
targetNode = node;
}
}
}
if (currentNode) {
event.preventDefault();
copyNode(currentNode);
return true;
}
}
return false;
},
clipboardTextSerializer: (slice) => { clipboardTextSerializer: (slice) => {
const isText = isPureText(slice.content.toJSON()); const isText = isPureText(slice.content.toJSON());
if (isText) { if (isText) {
return slice.content.textBetween(0, slice.content.size, '\n\n'); return slice.content.textBetween(0, slice.content.size, '\n\n');
} }

View File

@ -3,6 +3,10 @@ export const isMarkdown = (text: string): boolean => {
const html = text.match(/<\/?[a-z][\s\S]*>/i); const html = text.match(/<\/?[a-z][\s\S]*>/i);
if (html && html.length) return true; if (html && html.length) return true;
// image
const image = text.match(/!\[(\s|.)?\]\((\s|.)?\)/);
if (image && image.length) return true;
// table // table
const tables = text.match(/^\|(\S)*\|/gm); const tables = text.match(/^\|(\S)*\|/gm);
if (tables && tables.length) return true; if (tables && tables.length) return true;

View File

@ -63,7 +63,7 @@ function attrSet(token, name, value) {
function processToken(state: StateCore, options: TaskListsOptions): boolean { function processToken(state: StateCore, options: TaskListsOptions): boolean {
const allTokens = state.tokens; const allTokens = state.tokens;
attrSet(allTokens[0], 'class', 'contains-task-list'); attrSet(allTokens[0], 'class', '');
for (let i = 2; i < allTokens.length; i++) { for (let i = 2; i < allTokens.length; i++) {
if (!isTodoItem(allTokens, i)) { if (!isTodoItem(allTokens, i)) {

View File

@ -91,7 +91,7 @@ const SerializerConfig = {
}, },
nodes: { nodes: {
[Attachment.name]: renderCustomContainer('attachment'), attachment: renderCustomContainer('attachment'),
blockquote: (state, node) => { blockquote: (state, node) => {
if (node.attrs.multiline) { if (node.attrs.multiline) {
state.write('>>>'); state.write('>>>');

View File

@ -32,7 +32,9 @@ import { Blockquote } from './menus/blockquote';
import { HorizontalRule } from './menus/horizontal-rule'; import { HorizontalRule } from './menus/horizontal-rule';
import { Search } from './menus/search'; import { Search } from './menus/search';
import { Attachment } from './menus/attachment';
import { Callout } from './menus/callout'; import { Callout } from './menus/callout';
import { CodeBlock } from './menus/code-block';
import { Countdonw } from './menus/countdown'; import { Countdonw } from './menus/countdown';
import { DocumentChildren } from './menus/document-children'; import { DocumentChildren } from './menus/document-children';
import { DocumentReference } from './menus/document-reference'; import { DocumentReference } from './menus/document-reference';
@ -90,7 +92,9 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => {
<HorizontalRule editor={editor} /> <HorizontalRule editor={editor} />
<Search editor={editor} /> <Search editor={editor} />
<Attachment editor={editor} />
<Callout editor={editor} /> <Callout editor={editor} />
<CodeBlock editor={editor} />
<Countdonw editor={editor} /> <Countdonw editor={editor} />
<DocumentChildren editor={editor} /> <DocumentChildren editor={editor} />
<DocumentReference editor={editor} /> <DocumentReference editor={editor} />

View File

@ -0,0 +1,43 @@
import { Space, Button } from '@douyinfe/semi-ui';
import { IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { Attachment } from 'tiptap/extensions/attachment';
import { copyNode, deleteNode } from 'tiptap/prose-utils';
import { Divider } from 'tiptap/divider';
export const AttachmentBubbleMenu = ({ editor }) => {
return (
<BubbleMenu
className={'bubble-menu'}
editor={editor}
pluginKey="document-children-bubble-menu"
shouldShow={() => editor.isActive(Attachment.name)}
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
>
<Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(Attachment.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Divider />
<Tooltip content="删除节点" hideOnClick>
<Button
onClick={() => deleteNode(Attachment.name, editor)}
icon={<IconDelete />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
</Space>
</BubbleMenu>
);
};

View File

@ -0,0 +1,15 @@
import React from 'react';
import { Editor } from '@tiptap/core';
import { AttachmentBubbleMenu } from './bubble';
export const Attachment: React.FC<{ editor: Editor }> = ({ editor }) => {
if (!editor) {
return null;
}
return (
<>
<AttachmentBubbleMenu editor={editor} />
</>
);
};

View File

@ -1,13 +1,13 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import { Space, Button, Popover, Typography } from '@douyinfe/semi-ui'; import { Space, Button, Popover, Typography } from '@douyinfe/semi-ui';
import { IconDelete } from '@douyinfe/semi-icons'; import { IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { IconDrawBoard } from 'components/icons'; import { IconDrawBoard } from 'components/icons';
import { BubbleMenu } from 'tiptap/views/bubble-menu'; import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { Divider } from 'tiptap/divider'; import { Divider } from 'tiptap/divider';
import { Callout } from 'tiptap/extensions/callout'; import { Callout } from 'tiptap/extensions/callout';
import { deleteNode } from 'tiptap/prose-utils'; import { copyNode, deleteNode } from 'tiptap/prose-utils';
import styles from './bubble.module.scss'; import styles from './bubble.module.scss';
const { Text } = Typography; const { Text } = Typography;
@ -41,6 +41,16 @@ export const CalloutBubbleMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
matchRenderContainer={(node) => node && node.id === 'js-bannber-container'} matchRenderContainer={(node) => node && node.id === 'js-bannber-container'}
> >
<Space> <Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(Callout.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Popover <Popover
spacing={10} spacing={10}
style={{ padding: '0 12px 12px', overflow: 'hidden' }} style={{ padding: '0 12px 12px', overflow: 'hidden' }}
@ -93,7 +103,7 @@ export const CalloutBubbleMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
type="tertiary" type="tertiary"
theme="borderless" theme="borderless"
icon={<IconDelete />} icon={<IconDelete />}
onClick={() => deleteNode('callout', editor)} onClick={() => deleteNode(Callout.name, editor)}
/> />
</Tooltip> </Tooltip>
</Space> </Space>

View File

@ -0,0 +1,44 @@
import { Space, Button } from '@douyinfe/semi-ui';
import { IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { CodeBlock } from 'tiptap/extensions/code-block';
import { copyNode, deleteNode } from 'tiptap/prose-utils';
import { Divider } from 'tiptap/divider';
export const CodeBlockBubbleMenu = ({ editor }) => {
return (
<BubbleMenu
className={'bubble-menu'}
editor={editor}
pluginKey="document-children-bubble-menu"
shouldShow={() => editor.isActive(CodeBlock.name)}
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
matchRenderContainer={(node: HTMLElement) => node && node.classList && node.classList.contains('node-codeBlock')}
>
<Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(CodeBlock.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Divider />
<Tooltip content="删除节点" hideOnClick>
<Button
onClick={() => deleteNode(CodeBlock.name, editor)}
icon={<IconDelete />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
</Space>
</BubbleMenu>
);
};

View File

@ -0,0 +1,15 @@
import React from 'react';
import { Editor } from '@tiptap/core';
import { CodeBlockBubbleMenu } from './bubble';
export const CodeBlock: React.FC<{ editor: Editor }> = ({ editor }) => {
if (!editor) {
return null;
}
return (
<>
<CodeBlockBubbleMenu editor={editor} />
</>
);
};

View File

@ -1,10 +1,11 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Space, Button } from '@douyinfe/semi-ui'; import { Space, Button } from '@douyinfe/semi-ui';
import { IconEdit, IconDelete } from '@douyinfe/semi-icons'; import { IconEdit, IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from 'tiptap/views/bubble-menu'; import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { Countdown } from 'tiptap/extensions/countdown'; import { Countdown } from 'tiptap/extensions/countdown';
import { Divider } from 'tiptap/divider'; import { Divider } from 'tiptap/divider';
import { copyNode, deleteNode } from 'tiptap/prose-utils';
import { triggerOpenCountSettingModal } from '../_event'; import { triggerOpenCountSettingModal } from '../_event';
export const CountdownBubbleMenu = ({ editor }) => { export const CountdownBubbleMenu = ({ editor }) => {
@ -14,8 +15,6 @@ export const CountdownBubbleMenu = ({ editor }) => {
triggerOpenCountSettingModal(attrs); triggerOpenCountSettingModal(attrs);
}, [attrs]); }, [attrs]);
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
return ( return (
<BubbleMenu <BubbleMenu
className={'bubble-menu'} className={'bubble-menu'}
@ -25,6 +24,16 @@ export const CountdownBubbleMenu = ({ editor }) => {
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }} tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
> >
<Space> <Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(Countdown.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Tooltip content="编辑"> <Tooltip content="编辑">
<Button size="small" type="tertiary" theme="borderless" icon={<IconEdit />} onClick={openEditLinkModal} /> <Button size="small" type="tertiary" theme="borderless" icon={<IconEdit />} onClick={openEditLinkModal} />
</Tooltip> </Tooltip>
@ -32,7 +41,13 @@ export const CountdownBubbleMenu = ({ editor }) => {
<Divider /> <Divider />
<Tooltip content="删除节点" hideOnClick> <Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteNode} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" /> <Button
onClick={() => deleteNode(Countdown.name, editor)}
icon={<IconDelete />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip> </Tooltip>
</Space> </Space>
</BubbleMenu> </BubbleMenu>

View File

@ -1,13 +1,12 @@
import { useCallback } from 'react';
import { Space, Button } from '@douyinfe/semi-ui'; import { Space, Button } from '@douyinfe/semi-ui';
import { IconDelete } from '@douyinfe/semi-icons'; import { IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from 'tiptap/views/bubble-menu'; import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { DocumentChildren } from 'tiptap/extensions/document-children'; import { DocumentChildren } from 'tiptap/extensions/document-children';
import { copyNode, deleteNode } from 'tiptap/prose-utils';
import { Divider } from 'tiptap/divider';
export const DocumentChildrenBubbleMenu = ({ editor }) => { export const DocumentChildrenBubbleMenu = ({ editor }) => {
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
return ( return (
<BubbleMenu <BubbleMenu
className={'bubble-menu'} className={'bubble-menu'}
@ -17,8 +16,26 @@ export const DocumentChildrenBubbleMenu = ({ editor }) => {
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }} tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
> >
<Space> <Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(DocumentChildren.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Divider />
<Tooltip content="删除节点" hideOnClick> <Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteNode} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" /> <Button
onClick={() => deleteNode(DocumentChildren.name, editor)}
icon={<IconDelete />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip> </Tooltip>
</Space> </Space>
</BubbleMenu> </BubbleMenu>

View File

@ -1,7 +1,7 @@
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 { Space, Button, List, Popover, Typography } from '@douyinfe/semi-ui';
import { IconEdit, IconDelete } from '@douyinfe/semi-icons'; import { IconEdit, IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { DataRender } from 'components/data-render'; import { DataRender } from 'components/data-render';
import { IconDocument } from 'components/icons'; import { IconDocument } from 'components/icons';
@ -9,6 +9,7 @@ import { useWikiTocs } from 'data/wiki';
import { BubbleMenu } from 'tiptap/views/bubble-menu'; import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { DocumentReference } from 'tiptap/extensions/document-reference'; import { DocumentReference } from 'tiptap/extensions/document-reference';
import { Divider } from 'tiptap/divider'; import { Divider } from 'tiptap/divider';
import { copyNode, deleteNode } from 'tiptap/prose-utils';
const { Text } = Typography; const { Text } = Typography;
@ -32,8 +33,6 @@ export const DocumentReferenceBubbleMenu = ({ editor }) => {
[editor] [editor]
); );
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
return ( return (
<BubbleMenu <BubbleMenu
className={'bubble-menu'} className={'bubble-menu'}
@ -43,6 +42,16 @@ export const DocumentReferenceBubbleMenu = ({ editor }) => {
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }} tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
> >
<Space> <Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(DocumentReference.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Popover <Popover
spacing={15} spacing={15}
content={ content={
@ -88,7 +97,13 @@ export const DocumentReferenceBubbleMenu = ({ editor }) => {
<Divider /> <Divider />
<Tooltip content="删除节点" hideOnClick> <Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteNode} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" /> <Button
onClick={() => deleteNode(DocumentReference.name, editor)}
icon={<IconDelete />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip> </Tooltip>
</Space> </Space>
</BubbleMenu> </BubbleMenu>

View File

@ -1,11 +1,12 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { Space, Button, Modal, Form, Typography } from '@douyinfe/semi-ui'; import { Space, Button, Modal, Form, Typography } from '@douyinfe/semi-ui';
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'; import { FormApi } from '@douyinfe/semi-ui/lib/es/form';
import { IconEdit, IconExternalOpen, IconLineHeight, IconDelete } from '@douyinfe/semi-icons'; import { IconEdit, IconExternalOpen, IconLineHeight, IconCopy, IconDelete } from '@douyinfe/semi-icons';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from 'tiptap/views/bubble-menu'; import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { Iframe } from 'tiptap/extensions/iframe'; import { Iframe } from 'tiptap/extensions/iframe';
import { copyNode, deleteNode } from 'tiptap/prose-utils';
import { Divider } from 'tiptap/divider'; import { Divider } from 'tiptap/divider';
import { Size } from '../_components/size'; import { Size } from '../_components/size';
@ -57,8 +58,6 @@ export const IframeBubbleMenu = ({ editor }) => {
[editor] [editor]
); );
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
return ( return (
<BubbleMenu <BubbleMenu
className={'bubble-menu'} className={'bubble-menu'}
@ -95,6 +94,16 @@ export const IframeBubbleMenu = ({ editor }) => {
</Modal> </Modal>
<Space> <Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(Iframe.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Tooltip content="访问链接"> <Tooltip content="访问链接">
<Button size="small" type="tertiary" theme="borderless" icon={<IconExternalOpen />} onClick={visitLink} /> <Button size="small" type="tertiary" theme="borderless" icon={<IconExternalOpen />} onClick={visitLink} />
</Tooltip> </Tooltip>
@ -112,7 +121,13 @@ export const IframeBubbleMenu = ({ editor }) => {
<Divider /> <Divider />
<Tooltip content="删除节点" hideOnClick> <Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteNode} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" /> <Button
onClick={() => deleteNode(Iframe.name, editor)}
icon={<IconDelete />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip> </Tooltip>
</Space> </Space>
</BubbleMenu> </BubbleMenu>

View File

@ -1,11 +1,18 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Space, Button } from '@douyinfe/semi-ui'; import { Space, Button } from '@douyinfe/semi-ui';
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconLineHeight, IconDelete } from '@douyinfe/semi-icons'; import {
IconAlignLeft,
IconAlignCenter,
IconAlignRight,
IconLineHeight,
IconCopy,
IconDelete,
} from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from 'tiptap/views/bubble-menu'; import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { Divider } from 'tiptap/divider'; import { Divider } from 'tiptap/divider';
import { Image } from 'tiptap/extensions/image'; import { Image } from 'tiptap/extensions/image';
import { getEditorContainerDOMSize } from 'tiptap/prose-utils'; import { getEditorContainerDOMSize, copyNode, deleteNode } from 'tiptap/prose-utils';
import { Size } from '../_components/size'; import { Size } from '../_components/size';
export const ImageBubbleMenu = ({ editor }) => { export const ImageBubbleMenu = ({ editor }) => {
@ -32,6 +39,16 @@ export const ImageBubbleMenu = ({ editor }) => {
matchRenderContainer={(node) => node && node.id === 'js-resizeable-container'} matchRenderContainer={(node) => node && node.id === 'js-resizeable-container'}
> >
<Space> <Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(Image.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Tooltip content="左对齐"> <Tooltip content="左对齐">
<Button <Button
onClick={() => { onClick={() => {
@ -89,8 +106,6 @@ export const ImageBubbleMenu = ({ editor }) => {
/> />
</Tooltip> </Tooltip>
<Divider />
<Size <Size
width={width} width={width}
maxWidth={maxWidth} maxWidth={maxWidth}
@ -117,7 +132,7 @@ export const ImageBubbleMenu = ({ editor }) => {
type="tertiary" type="tertiary"
theme="borderless" theme="borderless"
icon={<IconDelete />} icon={<IconDelete />}
onClick={() => editor.chain().deleteSelection().run()} onClick={() => deleteNode(Image.name, editor)}
/> />
</Tooltip> </Tooltip>
</Space> </Space>

View File

@ -65,7 +65,10 @@ const COMMANDS = [
{ {
icon: <IconImage />, icon: <IconImage />,
label: '图片', label: '图片',
action: (editor) => editor.chain().focus().setEmptyImage().run(), action: (editor) => {
const { width } = getEditorContainerDOMSize(editor);
editor.chain().focus().setEmptyImage({ width }).run();
},
}, },
{ {
icon: <IconAttachment />, icon: <IconAttachment />,

View File

@ -1,11 +1,11 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Space, Button } from '@douyinfe/semi-ui'; import { Space, Button } from '@douyinfe/semi-ui';
import { IconLineHeight, IconDelete } from '@douyinfe/semi-icons'; import { IconCopy, IconLineHeight, IconDelete } from '@douyinfe/semi-icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { BubbleMenu } from 'tiptap/views/bubble-menu'; import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { Mind } from 'tiptap/extensions/mind'; import { Mind } from 'tiptap/extensions/mind';
import { Divider } from 'tiptap/divider'; import { Divider } from 'tiptap/divider';
import { getEditorContainerDOMSize } from 'tiptap/prose-utils'; import { getEditorContainerDOMSize, copyNode, deleteNode } from 'tiptap/prose-utils';
import { Size } from '../_components/size'; import { Size } from '../_components/size';
export const MindBubbleMenu = ({ editor }) => { export const MindBubbleMenu = ({ editor }) => {
@ -20,8 +20,6 @@ export const MindBubbleMenu = ({ editor }) => {
[editor] [editor]
); );
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
return ( return (
<BubbleMenu <BubbleMenu
className={'bubble-menu'} className={'bubble-menu'}
@ -32,6 +30,16 @@ export const MindBubbleMenu = ({ editor }) => {
matchRenderContainer={(node) => node && node.id === 'js-resizeable-container'} matchRenderContainer={(node) => node && node.id === 'js-resizeable-container'}
> >
<Space> <Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(Mind.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Size width={width} maxWidth={maxWidth} height={height} onOk={setSize}> <Size width={width} maxWidth={maxWidth} height={height} onOk={setSize}>
<Tooltip content="设置宽高"> <Tooltip content="设置宽高">
<Button icon={<IconLineHeight />} type="tertiary" theme="borderless" size="small" /> <Button icon={<IconLineHeight />} type="tertiary" theme="borderless" size="small" />
@ -39,7 +47,13 @@ export const MindBubbleMenu = ({ editor }) => {
</Size> </Size>
<Divider /> <Divider />
<Tooltip content="删除节点" hideOnClick> <Tooltip content="删除节点" hideOnClick>
<Button onClick={deleteNode} icon={<IconDelete />} type="tertiary" theme="borderless" size="small" /> <Button
onClick={() => deleteNode(Mind.name, editor)}
icon={<IconDelete />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip> </Tooltip>
</Space> </Space>
</BubbleMenu> </BubbleMenu>

View File

@ -1,4 +1,5 @@
import { Space, Button } from '@douyinfe/semi-ui'; import { Space, Button } from '@douyinfe/semi-ui';
import { IconCopy } from '@douyinfe/semi-icons';
import { import {
IconAddColumnBefore, IconAddColumnBefore,
IconAddColumnAfter, IconAddColumnAfter,
@ -17,6 +18,7 @@ import { Tooltip } from 'components/tooltip';
import { Divider } from 'tiptap/divider'; import { Divider } from 'tiptap/divider';
import { BubbleMenu } from 'tiptap/views/bubble-menu'; import { BubbleMenu } from 'tiptap/views/bubble-menu';
import { Table } from 'tiptap/extensions/table'; import { Table } from 'tiptap/extensions/table';
import { copyNode } from 'tiptap/prose-utils';
export const TableBubbleMenu = ({ editor }) => { export const TableBubbleMenu = ({ editor }) => {
return ( return (
@ -31,6 +33,18 @@ export const TableBubbleMenu = ({ editor }) => {
matchRenderContainer={(node: HTMLElement) => node && node.tagName === 'TABLE'} matchRenderContainer={(node: HTMLElement) => node && node.tagName === 'TABLE'}
> >
<Space> <Space>
<Tooltip content="复制">
<Button
onClick={() => copyNode(Table.name, editor)}
icon={<IconCopy />}
type="tertiary"
theme="borderless"
size="small"
/>
</Tooltip>
<Divider />
<Tooltip content="向前插入一列"> <Tooltip content="向前插入一列">
<Button <Button
onClick={() => editor.chain().focus().addColumnBefore().run()} onClick={() => editor.chain().focus().addColumnBefore().run()}

View File

@ -0,0 +1,53 @@
import { Editor } from '@tiptap/core';
import { Node, Fragment } from 'prosemirror-model';
import { copy } from 'helpers/copy';
import { safeJSONStringify } from 'helpers/json';
import { prosemirrorToMarkdown } from 'tiptap/markdown/prosemirror-to-markdown';
import { markdownToHTML } from 'tiptap/markdown/markdown-to-prosemirror/markdown-to-html';
export function copyNode(nodeOrNodeName: Node);
export function copyNode(nodeOrNodeName: string, editor: Editor);
export function copyNode(nodeOrNodeName: string | Node, editor?: Editor) {
let targetNode: null | Node = null;
if (typeof nodeOrNodeName === 'string') {
const { state } = editor;
const $pos = state.selection.$anchor;
// @ts-ignore
const currentNode = state.selection.node;
if (currentNode && currentNode.type.name === nodeOrNodeName) {
targetNode = currentNode;
} else {
if ($pos.depth) {
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
if (node.type.name === nodeOrNodeName) {
targetNode = node;
}
}
}
}
} else {
targetNode = nodeOrNodeName;
}
if (targetNode) {
const toCopy = [{ text: safeJSONStringify(targetNode.toJSON()), format: 'text/node' }];
if (targetNode.textContent) {
toCopy.push({ text: targetNode.textContent, format: 'text/plain' });
}
try {
const markdown = prosemirrorToMarkdown({ content: Fragment.from(targetNode) });
toCopy.push({ text: markdown, format: 'text/markdown' });
const html = markdownToHTML(markdown);
toCopy.push({ text: html, format: 'text/html' });
} catch (e) {}
copy(toCopy);
}
return false;
}

View File

@ -1,10 +1,10 @@
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
export function deleteNode(nodeType, editor: Editor) { export function deleteNode(nodeType: string, editor: Editor) {
const { state } = editor; const { state } = editor;
const $pos = state.selection.$anchor; const $pos = state.selection.$anchor;
if ($pos.depth) {
for (let d = $pos.depth; d > 0; d--) { for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d); const node = $pos.node(d);
if (node.type.name === nodeType) { if (node.type.name === nodeType) {
@ -15,5 +15,13 @@ export function deleteNode(nodeType, editor: Editor) {
return true; return true;
} }
} }
} else {
// @ts-ignore
const node = state.selection.node;
if (node && node.type.name === nodeType) {
editor.chain().deleteSelection().run();
}
}
return false; return false;
} }

View File

@ -2,6 +2,7 @@ export * from './active';
export * from './clamp'; export * from './clamp';
export * from './code'; export * from './code';
export * from './color'; export * from './color';
export * from './copy-node';
export * from './delete-node'; export * from './delete-node';
export * from './dom-dataset'; export * from './dom-dataset';
export * from './dom'; export * from './dom';

View File

@ -43,6 +43,7 @@
.node-attachment, .node-attachment,
.node-countdown, .node-countdown,
.node-iframe, .node-iframe,
.node-image,
.node-codeBlock, .node-codeBlock,
.node-documentChildren, .node-documentChildren,
.node-documentReference { .node-documentReference {

View File

@ -86,7 +86,6 @@ importers:
'@types/react': 17.0.38 '@types/react': 17.0.38
axios: ^0.25.0 axios: ^0.25.0
classnames: ^2.3.1 classnames: ^2.3.1
copy-to-clipboard: ^3.3.1
deep-equal: ^2.0.5 deep-equal: ^2.0.5
dompurify: ^2.3.5 dompurify: ^2.3.5
interactjs: ^1.10.11 interactjs: ^1.10.11
@ -115,6 +114,7 @@ importers:
scroll-into-view-if-needed: ^2.2.29 scroll-into-view-if-needed: ^2.2.29
swr: ^1.2.0 swr: ^1.2.0
tippy.js: ^6.3.7 tippy.js: ^6.3.7
toggle-selection: ^1.0.6
tsconfig-paths-webpack-plugin: ^3.5.2 tsconfig-paths-webpack-plugin: ^3.5.2
typescript: 4.5.5 typescript: 4.5.5
viewerjs: ^1.10.4 viewerjs: ^1.10.4
@ -169,7 +169,6 @@ importers:
'@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171 '@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171
axios: 0.25.0 axios: 0.25.0
classnames: 2.3.1 classnames: 2.3.1
copy-to-clipboard: 3.3.1
deep-equal: 2.0.5 deep-equal: 2.0.5
dompurify: 2.3.5 dompurify: 2.3.5
interactjs: 1.10.11 interactjs: 1.10.11
@ -198,6 +197,7 @@ importers:
scroll-into-view-if-needed: 2.2.29 scroll-into-view-if-needed: 2.2.29
swr: 1.2.0_react@17.0.2 swr: 1.2.0_react@17.0.2
tippy.js: 6.3.7 tippy.js: 6.3.7
toggle-selection: 1.0.6
viewerjs: 1.10.4 viewerjs: 1.10.4
y-indexeddb: 9.0.7_yjs@13.5.24 y-indexeddb: 9.0.7_yjs@13.5.24
y-prosemirror: 1.0.14_0fedec857d2fb730ad5b02a71124bf2a y-prosemirror: 1.0.14_0fedec857d2fb730ad5b02a71124bf2a
@ -3171,12 +3171,6 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: false dev: false
/copy-to-clipboard/3.3.1:
resolution: {integrity: sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw==}
dependencies:
toggle-selection: 1.0.6
dev: false
/copy-to/2.0.1: /copy-to/2.0.1:
resolution: {integrity: sha1-JoD7uAaKSNCGVrYJgJK9r8kG9KU=} resolution: {integrity: sha1-JoD7uAaKSNCGVrYJgJK9r8kG9KU=}
dev: false dev: false