mirror of https://github.com/fantasticit/think.git
tiptap: improve copy paste
parent
a066cea4a7
commit
cc3efef3f1
|
@ -56,7 +56,6 @@
|
|||
"@tiptap/suggestion": "^2.0.0-beta.90",
|
||||
"axios": "^0.25.0",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"deep-equal": "^2.0.5",
|
||||
"dompurify": "^2.3.5",
|
||||
"interactjs": "^1.10.11",
|
||||
|
@ -85,6 +84,7 @@
|
|||
"scroll-into-view-if-needed": "^2.2.29",
|
||||
"swr": "^1.2.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"toggle-selection": "^1.0.6",
|
||||
"viewerjs": "^1.10.4",
|
||||
"y-indexeddb": "^9.0.7",
|
||||
"y-prosemirror": "^1.0.14",
|
||||
|
|
|
@ -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;
|
|
@ -1,7 +1,16 @@
|
|||
import _copy from 'copy-to-clipboard';
|
||||
import _copy from './copy-to-clipboard';
|
||||
import { Toast } from '@douyinfe/semi-ui';
|
||||
|
||||
export function copy(text: string, msg = '复制成功') {
|
||||
Toast.success(msg);
|
||||
return _copy(text);
|
||||
interface Options {
|
||||
debug?: boolean;
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import {
|
|||
markdownToProsemirror,
|
||||
prosemirrorToMarkdown,
|
||||
} from 'tiptap/markdown/markdown-to-prosemirror';
|
||||
import { copyNode } from 'tiptap/prose-utils';
|
||||
import { safeJSONParse } from 'helpers/json';
|
||||
|
||||
const isPureText = (content): boolean => {
|
||||
if (!content) return false;
|
||||
|
@ -43,7 +45,6 @@ export const Paste = Extension.create({
|
|||
if (!event.clipboardData) return false;
|
||||
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
|
||||
if (files.length) {
|
||||
event.preventDefault();
|
||||
files.forEach((file) => {
|
||||
|
@ -55,6 +56,16 @@ export const Paste = Extension.create({
|
|||
const text = event.clipboardData.getData('text/plain');
|
||||
const html = event.clipboardData.getData('text/html');
|
||||
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)) {
|
||||
|
@ -80,14 +91,14 @@ export const Paste = Extension.create({
|
|||
}
|
||||
|
||||
// 处理 markdown
|
||||
if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
|
||||
if (markdownText || isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
|
||||
event.preventDefault();
|
||||
const firstNode = view.props.state.doc.content.firstChild;
|
||||
const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0;
|
||||
const schema = view.props.state.schema;
|
||||
const doc = markdownToProsemirror({
|
||||
schema,
|
||||
content: normalizePastedMarkdown(text),
|
||||
content: normalizePastedMarkdown(markdownText || text),
|
||||
hasTitle,
|
||||
});
|
||||
let tr = view.state.tr;
|
||||
|
@ -130,8 +141,41 @@ export const Paste = Extension.create({
|
|||
|
||||
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) => {
|
||||
const isText = isPureText(slice.content.toJSON());
|
||||
|
||||
if (isText) {
|
||||
return slice.content.textBetween(0, slice.content.size, '\n\n');
|
||||
}
|
||||
|
|
|
@ -3,6 +3,10 @@ export const isMarkdown = (text: string): boolean => {
|
|||
const html = text.match(/<\/?[a-z][\s\S]*>/i);
|
||||
if (html && html.length) return true;
|
||||
|
||||
// image
|
||||
const image = text.match(/!\[(\s|.)?\]\((\s|.)?\)/);
|
||||
if (image && image.length) return true;
|
||||
|
||||
// table
|
||||
const tables = text.match(/^\|(\S)*\|/gm);
|
||||
if (tables && tables.length) return true;
|
||||
|
|
|
@ -63,7 +63,7 @@ function attrSet(token, name, value) {
|
|||
function processToken(state: StateCore, options: TaskListsOptions): boolean {
|
||||
const allTokens = state.tokens;
|
||||
|
||||
attrSet(allTokens[0], 'class', 'contains-task-list');
|
||||
attrSet(allTokens[0], 'class', '');
|
||||
|
||||
for (let i = 2; i < allTokens.length; i++) {
|
||||
if (!isTodoItem(allTokens, i)) {
|
||||
|
|
|
@ -91,7 +91,7 @@ const SerializerConfig = {
|
|||
},
|
||||
|
||||
nodes: {
|
||||
[Attachment.name]: renderCustomContainer('attachment'),
|
||||
attachment: renderCustomContainer('attachment'),
|
||||
blockquote: (state, node) => {
|
||||
if (node.attrs.multiline) {
|
||||
state.write('>>>');
|
||||
|
|
|
@ -32,7 +32,9 @@ import { Blockquote } from './menus/blockquote';
|
|||
import { HorizontalRule } from './menus/horizontal-rule';
|
||||
import { Search } from './menus/search';
|
||||
|
||||
import { Attachment } from './menus/attachment';
|
||||
import { Callout } from './menus/callout';
|
||||
import { CodeBlock } from './menus/code-block';
|
||||
import { Countdonw } from './menus/countdown';
|
||||
import { DocumentChildren } from './menus/document-children';
|
||||
import { DocumentReference } from './menus/document-reference';
|
||||
|
@ -90,7 +92,9 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => {
|
|||
<HorizontalRule editor={editor} />
|
||||
<Search editor={editor} />
|
||||
|
||||
<Attachment editor={editor} />
|
||||
<Callout editor={editor} />
|
||||
<CodeBlock editor={editor} />
|
||||
<Countdonw editor={editor} />
|
||||
<DocumentChildren editor={editor} />
|
||||
<DocumentReference editor={editor} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,13 +1,13 @@
|
|||
import { useCallback } from 'react';
|
||||
import { Editor } from '@tiptap/core';
|
||||
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 { IconDrawBoard } from 'components/icons';
|
||||
import { BubbleMenu } from 'tiptap/views/bubble-menu';
|
||||
import { Divider } from 'tiptap/divider';
|
||||
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';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
@ -41,6 +41,16 @@ export const CalloutBubbleMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
|
|||
matchRenderContainer={(node) => node && node.id === 'js-bannber-container'}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="复制">
|
||||
<Button
|
||||
onClick={() => copyNode(Callout.name, editor)}
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Popover
|
||||
spacing={10}
|
||||
style={{ padding: '0 12px 12px', overflow: 'hidden' }}
|
||||
|
@ -93,7 +103,7 @@ export const CalloutBubbleMenu: React.FC<{ editor: Editor }> = ({ editor }) => {
|
|||
type="tertiary"
|
||||
theme="borderless"
|
||||
icon={<IconDelete />}
|
||||
onClick={() => deleteNode('callout', editor)}
|
||||
onClick={() => deleteNode(Callout.name, editor)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,10 +1,11 @@
|
|||
import { useCallback } from 'react';
|
||||
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 { BubbleMenu } from 'tiptap/views/bubble-menu';
|
||||
import { Countdown } from 'tiptap/extensions/countdown';
|
||||
import { Divider } from 'tiptap/divider';
|
||||
import { copyNode, deleteNode } from 'tiptap/prose-utils';
|
||||
import { triggerOpenCountSettingModal } from '../_event';
|
||||
|
||||
export const CountdownBubbleMenu = ({ editor }) => {
|
||||
|
@ -14,8 +15,6 @@ export const CountdownBubbleMenu = ({ editor }) => {
|
|||
triggerOpenCountSettingModal(attrs);
|
||||
}, [attrs]);
|
||||
|
||||
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
className={'bubble-menu'}
|
||||
|
@ -25,6 +24,16 @@ export const CountdownBubbleMenu = ({ editor }) => {
|
|||
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="复制">
|
||||
<Button
|
||||
onClick={() => copyNode(Countdown.name, editor)}
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="编辑">
|
||||
<Button size="small" type="tertiary" theme="borderless" icon={<IconEdit />} onClick={openEditLinkModal} />
|
||||
</Tooltip>
|
||||
|
@ -32,7 +41,13 @@ export const CountdownBubbleMenu = ({ editor }) => {
|
|||
<Divider />
|
||||
|
||||
<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>
|
||||
</Space>
|
||||
</BubbleMenu>
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { useCallback } from 'react';
|
||||
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 { BubbleMenu } from 'tiptap/views/bubble-menu';
|
||||
import { DocumentChildren } from 'tiptap/extensions/document-children';
|
||||
import { copyNode, deleteNode } from 'tiptap/prose-utils';
|
||||
import { Divider } from 'tiptap/divider';
|
||||
|
||||
export const DocumentChildrenBubbleMenu = ({ editor }) => {
|
||||
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
className={'bubble-menu'}
|
||||
|
@ -17,8 +16,26 @@ export const DocumentChildrenBubbleMenu = ({ editor }) => {
|
|||
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="复制">
|
||||
<Button
|
||||
onClick={() => copyNode(DocumentChildren.name, editor)}
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<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>
|
||||
</Space>
|
||||
</BubbleMenu>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
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 { DataRender } from 'components/data-render';
|
||||
import { IconDocument } from 'components/icons';
|
||||
|
@ -9,6 +9,7 @@ import { useWikiTocs } from 'data/wiki';
|
|||
import { BubbleMenu } from 'tiptap/views/bubble-menu';
|
||||
import { DocumentReference } from 'tiptap/extensions/document-reference';
|
||||
import { Divider } from 'tiptap/divider';
|
||||
import { copyNode, deleteNode } from 'tiptap/prose-utils';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
@ -32,8 +33,6 @@ export const DocumentReferenceBubbleMenu = ({ editor }) => {
|
|||
[editor]
|
||||
);
|
||||
|
||||
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
className={'bubble-menu'}
|
||||
|
@ -43,6 +42,16 @@ export const DocumentReferenceBubbleMenu = ({ editor }) => {
|
|||
tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="复制">
|
||||
<Button
|
||||
onClick={() => copyNode(DocumentReference.name, editor)}
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Popover
|
||||
spacing={15}
|
||||
content={
|
||||
|
@ -88,7 +97,13 @@ export const DocumentReferenceBubbleMenu = ({ editor }) => {
|
|||
<Divider />
|
||||
|
||||
<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>
|
||||
</Space>
|
||||
</BubbleMenu>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
import { Space, Button, Modal, Form, Typography } from '@douyinfe/semi-ui';
|
||||
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 { Tooltip } from 'components/tooltip';
|
||||
import { BubbleMenu } from 'tiptap/views/bubble-menu';
|
||||
import { Iframe } from 'tiptap/extensions/iframe';
|
||||
import { copyNode, deleteNode } from 'tiptap/prose-utils';
|
||||
import { Divider } from 'tiptap/divider';
|
||||
import { Size } from '../_components/size';
|
||||
|
||||
|
@ -57,8 +58,6 @@ export const IframeBubbleMenu = ({ editor }) => {
|
|||
[editor]
|
||||
);
|
||||
|
||||
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
className={'bubble-menu'}
|
||||
|
@ -95,6 +94,16 @@ export const IframeBubbleMenu = ({ editor }) => {
|
|||
</Modal>
|
||||
|
||||
<Space>
|
||||
<Tooltip content="复制">
|
||||
<Button
|
||||
onClick={() => copyNode(Iframe.name, editor)}
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="访问链接">
|
||||
<Button size="small" type="tertiary" theme="borderless" icon={<IconExternalOpen />} onClick={visitLink} />
|
||||
</Tooltip>
|
||||
|
@ -112,7 +121,13 @@ export const IframeBubbleMenu = ({ editor }) => {
|
|||
<Divider />
|
||||
|
||||
<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>
|
||||
</Space>
|
||||
</BubbleMenu>
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
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 { BubbleMenu } from 'tiptap/views/bubble-menu';
|
||||
import { Divider } from 'tiptap/divider';
|
||||
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';
|
||||
|
||||
export const ImageBubbleMenu = ({ editor }) => {
|
||||
|
@ -32,6 +39,16 @@ export const ImageBubbleMenu = ({ editor }) => {
|
|||
matchRenderContainer={(node) => node && node.id === 'js-resizeable-container'}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="复制">
|
||||
<Button
|
||||
onClick={() => copyNode(Image.name, editor)}
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="左对齐">
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
@ -89,8 +106,6 @@ export const ImageBubbleMenu = ({ editor }) => {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Size
|
||||
width={width}
|
||||
maxWidth={maxWidth}
|
||||
|
@ -117,7 +132,7 @@ export const ImageBubbleMenu = ({ editor }) => {
|
|||
type="tertiary"
|
||||
theme="borderless"
|
||||
icon={<IconDelete />}
|
||||
onClick={() => editor.chain().deleteSelection().run()}
|
||||
onClick={() => deleteNode(Image.name, editor)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
|
|
@ -65,7 +65,10 @@ const COMMANDS = [
|
|||
{
|
||||
icon: <IconImage />,
|
||||
label: '图片',
|
||||
action: (editor) => editor.chain().focus().setEmptyImage().run(),
|
||||
action: (editor) => {
|
||||
const { width } = getEditorContainerDOMSize(editor);
|
||||
editor.chain().focus().setEmptyImage({ width }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <IconAttachment />,
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { useCallback } from 'react';
|
||||
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 { BubbleMenu } from 'tiptap/views/bubble-menu';
|
||||
import { Mind } from 'tiptap/extensions/mind';
|
||||
import { Divider } from 'tiptap/divider';
|
||||
import { getEditorContainerDOMSize } from 'tiptap/prose-utils';
|
||||
import { getEditorContainerDOMSize, copyNode, deleteNode } from 'tiptap/prose-utils';
|
||||
import { Size } from '../_components/size';
|
||||
|
||||
export const MindBubbleMenu = ({ editor }) => {
|
||||
|
@ -20,8 +20,6 @@ export const MindBubbleMenu = ({ editor }) => {
|
|||
[editor]
|
||||
);
|
||||
|
||||
const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
className={'bubble-menu'}
|
||||
|
@ -32,6 +30,16 @@ export const MindBubbleMenu = ({ editor }) => {
|
|||
matchRenderContainer={(node) => node && node.id === 'js-resizeable-container'}
|
||||
>
|
||||
<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}>
|
||||
<Tooltip content="设置宽高">
|
||||
<Button icon={<IconLineHeight />} type="tertiary" theme="borderless" size="small" />
|
||||
|
@ -39,7 +47,13 @@ export const MindBubbleMenu = ({ editor }) => {
|
|||
</Size>
|
||||
<Divider />
|
||||
<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>
|
||||
</Space>
|
||||
</BubbleMenu>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Space, Button } from '@douyinfe/semi-ui';
|
||||
import { IconCopy } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconAddColumnBefore,
|
||||
IconAddColumnAfter,
|
||||
|
@ -17,6 +18,7 @@ import { Tooltip } from 'components/tooltip';
|
|||
import { Divider } from 'tiptap/divider';
|
||||
import { BubbleMenu } from 'tiptap/views/bubble-menu';
|
||||
import { Table } from 'tiptap/extensions/table';
|
||||
import { copyNode } from 'tiptap/prose-utils';
|
||||
|
||||
export const TableBubbleMenu = ({ editor }) => {
|
||||
return (
|
||||
|
@ -31,6 +33,18 @@ export const TableBubbleMenu = ({ editor }) => {
|
|||
matchRenderContainer={(node: HTMLElement) => node && node.tagName === 'TABLE'}
|
||||
>
|
||||
<Space>
|
||||
<Tooltip content="复制">
|
||||
<Button
|
||||
onClick={() => copyNode(Table.name, editor)}
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip content="向前插入一列">
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().addColumnBefore().run()}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,19 +1,27 @@
|
|||
import { Editor } from '@tiptap/core';
|
||||
|
||||
export function deleteNode(nodeType, editor: Editor) {
|
||||
export function deleteNode(nodeType: string, editor: Editor) {
|
||||
const { state } = editor;
|
||||
|
||||
const $pos = state.selection.$anchor;
|
||||
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
const node = $pos.node(d);
|
||||
if (node.type.name === nodeType) {
|
||||
// @ts-ignore
|
||||
if (editor.dispatchTransaction)
|
||||
if ($pos.depth) {
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
const node = $pos.node(d);
|
||||
if (node.type.name === nodeType) {
|
||||
// @ts-ignore
|
||||
editor.dispatchTransaction(state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView());
|
||||
return true;
|
||||
if (editor.dispatchTransaction)
|
||||
// @ts-ignore
|
||||
editor.dispatchTransaction(state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const node = state.selection.node;
|
||||
if (node && node.type.name === nodeType) {
|
||||
editor.chain().deleteSelection().run();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ export * from './active';
|
|||
export * from './clamp';
|
||||
export * from './code';
|
||||
export * from './color';
|
||||
export * from './copy-node';
|
||||
export * from './delete-node';
|
||||
export * from './dom-dataset';
|
||||
export * from './dom';
|
||||
|
|
|
@ -43,6 +43,7 @@
|
|||
.node-attachment,
|
||||
.node-countdown,
|
||||
.node-iframe,
|
||||
.node-image,
|
||||
.node-codeBlock,
|
||||
.node-documentChildren,
|
||||
.node-documentReference {
|
||||
|
|
|
@ -86,7 +86,6 @@ importers:
|
|||
'@types/react': 17.0.38
|
||||
axios: ^0.25.0
|
||||
classnames: ^2.3.1
|
||||
copy-to-clipboard: ^3.3.1
|
||||
deep-equal: ^2.0.5
|
||||
dompurify: ^2.3.5
|
||||
interactjs: ^1.10.11
|
||||
|
@ -115,6 +114,7 @@ importers:
|
|||
scroll-into-view-if-needed: ^2.2.29
|
||||
swr: ^1.2.0
|
||||
tippy.js: ^6.3.7
|
||||
toggle-selection: ^1.0.6
|
||||
tsconfig-paths-webpack-plugin: ^3.5.2
|
||||
typescript: 4.5.5
|
||||
viewerjs: ^1.10.4
|
||||
|
@ -169,7 +169,6 @@ importers:
|
|||
'@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171
|
||||
axios: 0.25.0
|
||||
classnames: 2.3.1
|
||||
copy-to-clipboard: 3.3.1
|
||||
deep-equal: 2.0.5
|
||||
dompurify: 2.3.5
|
||||
interactjs: 1.10.11
|
||||
|
@ -198,6 +197,7 @@ importers:
|
|||
scroll-into-view-if-needed: 2.2.29
|
||||
swr: 1.2.0_react@17.0.2
|
||||
tippy.js: 6.3.7
|
||||
toggle-selection: 1.0.6
|
||||
viewerjs: 1.10.4
|
||||
y-indexeddb: 9.0.7_yjs@13.5.24
|
||||
y-prosemirror: 1.0.14_0fedec857d2fb730ad5b02a71124bf2a
|
||||
|
@ -3171,12 +3171,6 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
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:
|
||||
resolution: {integrity: sha1-JoD7uAaKSNCGVrYJgJK9r8kG9KU=}
|
||||
dev: false
|
||||
|
|
Loading…
Reference in New Issue