diff --git a/packages/client/src/tiptap/extensions/iframe.ts b/packages/client/src/tiptap/extensions/iframe.ts index 4fc18502..93a1f7b1 100644 --- a/packages/client/src/tiptap/extensions/iframe.ts +++ b/packages/client/src/tiptap/extensions/iframe.ts @@ -34,7 +34,7 @@ export const Iframe = Node.create({ parseHTML: getDatasetAttribute('width'), }, height: { - default: 200, + default: 66, parseHTML: getDatasetAttribute('height'), }, url: { diff --git a/packages/client/src/tiptap/extensions/link.ts b/packages/client/src/tiptap/extensions/link.ts index 21ef8393..057283f7 100644 --- a/packages/client/src/tiptap/extensions/link.ts +++ b/packages/client/src/tiptap/extensions/link.ts @@ -53,9 +53,8 @@ export const Link = BuiltInLink.extend({ title: null, parseHTML: (element) => element.getAttribute('title'), }, - canonicalSrc: { - default: null, - parseHTML: (element) => element.dataset.canonicalSrc, + hasTrigger: { + default: false, }, }; }, diff --git a/packages/client/src/tiptap/menubar.tsx b/packages/client/src/tiptap/menubar.tsx index 2b8eafab..2de6181e 100644 --- a/packages/client/src/tiptap/menubar.tsx +++ b/packages/client/src/tiptap/menubar.tsx @@ -15,6 +15,7 @@ import { BaseBubbleMenu } from './menus/base-bubble-menu'; import { ImageBubbleMenu } from './menus/image'; import { BannerBubbleMenu } from './menus/banner'; import { LinkBubbleMenu } from './menus/link'; +import { IframeBubbleMenu } from './menus/iframe'; import { TableBubbleMenu } from './menus/table'; export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => { @@ -76,6 +77,7 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => { + diff --git a/packages/client/src/tiptap/menus/iframe.tsx b/packages/client/src/tiptap/menus/iframe.tsx new file mode 100644 index 00000000..42363853 --- /dev/null +++ b/packages/client/src/tiptap/menus/iframe.tsx @@ -0,0 +1,120 @@ +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 { useToggle } from 'hooks/use-toggle'; +import { Tooltip } from 'components/tooltip'; +import { BubbleMenu } from '../views/bubble-menu'; +import { Iframe } from '../extensions/iframe'; +import { Divider } from '../divider'; +import { Size } from './size'; + +const { Text } = Typography; + +const EXAMPLE_LINK = + 'https://proxy.tencentsuite.com/openapi/proxy/v2/addon?uid=144115212008575217&creator=144115212008575217&redirect=https%3A%2F%2Fi.y.qq.com%2Fn2%2Fm%2Foutchain%2Fplayer%2Findex.html%3Fsongid%3D5408217&docType=1&docID=300000000$RwqOunTcpXjs&addonID=0b69e1b9517e44a4aee35d33ee021b55&packageID=817&nonce=m3rqxn'; + +export const IframeBubbleMenu = ({ editor }) => { + const attrs = editor.getAttributes(Iframe.name); + const { width, height, url } = attrs; + const $form = useRef(); + const [visible, toggleVisible] = useToggle(false); + + const useExample = useCallback(() => { + $form.current.setValue('url', EXAMPLE_LINK); + }, []); + + const handleCancel = useCallback(() => { + toggleVisible(false); + }, []); + + const handleOk = useCallback(() => { + $form.current.validate().then((values) => { + editor + .chain() + .updateAttributes(Iframe.name, { + url: values.url, + }) + .setNodeSelection(editor.state.selection.from) + .focus() + .run(); + toggleVisible(false); + }); + }, []); + + const visitLink = useCallback(() => { + window.open(url, '_blank'); + }, [url]); + + const openEditLinkModal = useCallback(() => { + toggleVisible(true); + }, []); + + const setSize = useCallback( + (size) => { + editor.chain().updateAttributes(Iframe.name, size).setNodeSelection(editor.state.selection.from).focus().run(); + }, + [editor] + ); + + const deleteNode = useCallback(() => editor.chain().deleteSelection().run(), [editor]); + + return ( + editor.isActive(Iframe.name)} + tippyOptions={{ maxWidth: 456 }} + > + toggleVisible(false)} + centered + footer={ + + + 查看示例 + + + + 取消 + + + 确认 + + + + } + > + ($form.current = formApi)} labelPosition="left"> + + + + + + + } onClick={visitLink} /> + + + + } onClick={openEditLinkModal} /> + + + + + } type="tertiary" theme="borderless" size="small" /> + + + + + + + } type="tertiary" theme="borderless" size="small" /> + + + + ); +}; diff --git a/packages/client/src/tiptap/menus/image.tsx b/packages/client/src/tiptap/menus/image.tsx index c481643f..3a7f31d1 100644 --- a/packages/client/src/tiptap/menus/image.tsx +++ b/packages/client/src/tiptap/menus/image.tsx @@ -1,14 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Space, Button, InputNumber, Typography } from '@douyinfe/semi-ui'; -import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconUpload, IconDelete } from '@douyinfe/semi-icons'; +import { Space, Button } from '@douyinfe/semi-ui'; +import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconLineHeight, IconDelete } from '@douyinfe/semi-icons'; import { Tooltip } from 'components/tooltip'; -import { Upload } from 'components/upload'; import { BubbleMenu } from '../views/bubble-menu'; import { Divider } from '../divider'; import { Image } from '../extensions/image'; -import { getImageOriginSize } from '../services/image'; - -const { Text } = Typography; +import { Size } from './size'; export const ImageBubbleMenu = ({ editor }) => { const attrs = editor.getAttributes(Image.name); @@ -92,43 +89,22 @@ export const ImageBubbleMenu = ({ editor }) => { - 宽 - { - const value = (e.target as HTMLInputElement).value; + { editor .chain() - .updateAttributes(Image.name, { - width: value, - }) + .updateAttributes(Image.name, size) .setNodeSelection(editor.state.selection.from) .focus() .run(); }} - /> - - 高 - { - const value = (e.target as HTMLInputElement).value; - editor - .chain() - .updateAttributes(Image.name, { - height: value, - }) - .setNodeSelection(editor.state.selection.from) - .focus() - .run(); - }} - /> + > + + } type="tertiary" theme="borderless" size="small" /> + + diff --git a/packages/client/src/tiptap/menus/link.tsx b/packages/client/src/tiptap/menus/link.tsx index a0aa0554..f00d8b62 100644 --- a/packages/client/src/tiptap/menus/link.tsx +++ b/packages/client/src/tiptap/menus/link.tsx @@ -1,18 +1,89 @@ -import { useEffect, useState } from 'react'; -import { Space, Button, Input } from '@douyinfe/semi-ui'; -import { IconExternalOpen, IconUnlink, IconTickCircle } from '@douyinfe/semi-icons'; +import { useEffect, useState, useRef, useCallback } from 'react'; +import { Space, Button, Modal, Form, Toast } from '@douyinfe/semi-ui'; +import { FormApi } from '@douyinfe/semi-ui/lib/es/form'; +import { IconExternalOpen, IconUnlink, IconEdit } from '@douyinfe/semi-icons'; +import { useToggle } from 'hooks/use-toggle'; import { Tooltip } from 'components/tooltip'; +import { Divider } from '../divider'; import { BubbleMenu } from '../views/bubble-menu'; import { Link } from '../extensions/link'; +import { isMarkActive } from '../services/is-active'; +import { findMarkPosition } from '../services/find-position'; +import { isValidURL } from '../services/valid-url'; export const LinkBubbleMenu = ({ editor }) => { const attrs = editor.getAttributes(Link.name); const { href, target } = attrs; - const [url, setUrl] = useState(''); + const isLinkActive = editor.isActive(Link.name); + const $form = useRef(); + const [visible, toggleVisible] = useToggle(false); + const [text, setText] = useState(); + + const handleCancel = useCallback(() => { + toggleVisible(false); + }, []); + + const handleOk = useCallback(() => { + $form.current.validate().then((values) => { + if (!values.href) { + Toast.error('请输入有效链接'); + return; + } + + if (!values.text) { + values.text = values.href; + } + + if (values.text !== text && values.text) { + editor.chain().extendMarkRange(Link.name).run(); + const { view } = editor; + const schema = view.state.schema; + const node = schema.text(values.text, [schema.marks.link.create({ href: values.href })]); + view.dispatch(view.state.tr.replaceSelectionWith(node, true).scrollIntoView()); + } else { + editor + .chain() + .extendMarkRange(Link.name) + .updateAttributes(Link.name, { + href: values.href, + }) + .focus() + .run(); + } + toggleVisible(false); + }); + }, []); + + const visitLink = useCallback(() => { + window.open(href, target); + }, [href, target]); + + const openEditLinkModal = useCallback(() => { + toggleVisible(true); + }, []); + + const unsetLink = useCallback(() => editor.chain().unsetLink().run(), [editor]); useEffect(() => { - setUrl(href); - }, [href]); + if (!isLinkActive) return; + + const { state } = editor; + const isInLink = isMarkActive(state.schema.marks.link)(state); + + if (!isInLink) return; + + const { $head } = editor.state.selection; + const marks = $head.marks(); + if (!marks.length) return; + + const mark = marks[0]; + const node = $head.node($head.depth); + const startPosOfThisLine = $head.pos - $head.parentOffset; + const endPosOfThisLine = startPosOfThisLine + node.content.size; + const { start, end } = findMarkPosition(state, mark, startPosOfThisLine, endPosOfThisLine); + const text = state.doc.textBetween(start, end); + setText(text); + }); return ( { shouldShow={() => editor.isActive(Link.name)} tippyOptions={{ maxWidth: 456 }} > + + ($form.current = formApi)} labelPosition="left"> + + isValidURL(value), message: '请输入有效链接' }]} + > + + + - setUrl(v)} - placeholder={'输入链接'} - onEnterPress={(e) => { - const url = (e.target as HTMLInputElement).value; - setUrl(url); - }} - /> - - - } - onClick={() => { - editor - .chain() - .extendMarkRange(Link.name) - .updateAttributes(Link.name, { - href: url, - }) - .focus() - .run(); - }} - /> - - - - { - editor.chain().unsetLink().run(); - }} - icon={} - type="tertiary" - theme="borderless" - size="small" - /> - - - } - onClick={() => { - if (href) { - window.open(href, target); - } - }} - /> + } onClick={visitLink} /> + + + + } onClick={openEditLinkModal} /> + + + + + + } type="tertiary" theme="borderless" size="small" /> diff --git a/packages/client/src/tiptap/menus/paragraph.tsx b/packages/client/src/tiptap/menus/paragraph.tsx index 18ec3756..e19a0fdb 100644 --- a/packages/client/src/tiptap/menus/paragraph.tsx +++ b/packages/client/src/tiptap/menus/paragraph.tsx @@ -21,8 +21,6 @@ export const Paragraph = ({ editor }) => { } }, []); - // console.log(getCurrentCaretTitle(editor)); - return ( void }> = ({ + width, + height, + onOk, + children, +}) => { + const $form = useRef(); + + const handleOk = useCallback(() => { + $form.current.validate().then((values) => { + onOk(values as ISize); + }); + }, []); + + return ( + + ($form.current = formApi)} labelPosition="left"> + + + + + 设置 + + + } + > + {children} + + ); +}; diff --git a/packages/client/src/tiptap/services/find-position.ts b/packages/client/src/tiptap/services/find-position.ts new file mode 100644 index 00000000..aad16ee0 --- /dev/null +++ b/packages/client/src/tiptap/services/find-position.ts @@ -0,0 +1,18 @@ +import { EditorState } from 'prosemirror-state'; + +export function findMarkPosition(state: EditorState, mark, from, to) { + let markPos = { start: -1, end: -1 }; + state.doc.nodesBetween(from, to, (node, pos) => { + if (markPos.start > -1) { + return false; + } + if (markPos.start === -1 && mark.isInSet(node.marks)) { + markPos = { + start: pos, + end: pos + Math.max(node.textContent.length, 1), + }; + } + }); + + return markPos; +} diff --git a/packages/client/src/tiptap/services/image.ts b/packages/client/src/tiptap/services/image.ts deleted file mode 100644 index 2ab06cfa..00000000 --- a/packages/client/src/tiptap/services/image.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function getImageOriginSize(src: string): Promise<{ width: number | string; height: number | string }> { - return new Promise((resolve) => { - const image = document.createElement('img'); - image.onload = function () { - console.log(image.width, image.height); - resolve({ width: image.width, height: image.height }); - }; - image.onerror = function () { - resolve({ width: 'auto', height: 'auto' }); - }; - image.src = src; - }); -} diff --git a/packages/client/src/tiptap/services/valid-url.ts b/packages/client/src/tiptap/services/valid-url.ts new file mode 100644 index 00000000..134911a2 --- /dev/null +++ b/packages/client/src/tiptap/services/valid-url.ts @@ -0,0 +1,12 @@ +export function isValidURL(str) { + var pattern = new RegExp( + '^(https?:\\/\\/)?' + // protocol + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name + '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path + '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string + '(\\#[-a-z\\d_]*)?$', + 'i' + ); + return !!pattern.test(str); +} diff --git a/packages/client/src/tiptap/wrappers/iframe/index.module.scss b/packages/client/src/tiptap/wrappers/iframe/index.module.scss index b7d2343b..c0aa550a 100644 --- a/packages/client/src/tiptap/wrappers/iframe/index.module.scss +++ b/packages/client/src/tiptap/wrappers/iframe/index.module.scss @@ -22,6 +22,13 @@ flex: 1; } + .emptyWrap { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + } + :global { iframe { width: 100%; diff --git a/packages/client/src/tiptap/wrappers/iframe/index.tsx b/packages/client/src/tiptap/wrappers/iframe/index.tsx index f1765828..e5b3bb21 100644 --- a/packages/client/src/tiptap/wrappers/iframe/index.tsx +++ b/packages/client/src/tiptap/wrappers/iframe/index.tsx @@ -1,9 +1,11 @@ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import cls from 'classnames'; -import { Input } from '@douyinfe/semi-ui'; +import { Input, Typography, Space } from '@douyinfe/semi-ui'; import { Resizeable } from 'components/resizeable'; import styles from './index.module.scss'; +const { Text } = Typography; + export const IframeWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; const { url, width, height } = node.attrs; @@ -13,15 +15,19 @@ export const IframeWrapper = ({ editor, node, updateAttributes }) => { }; const content = ( - {isEditable && ( + {/* {isEditable && ( updateAttributes({ url })}> - )} - {url && ( + )} */} + {url ? ( + ) : ( + + 请设置外链地址 + )} );