diff --git a/packages/client/src/components/emoji-picker/index.tsx b/packages/client/src/components/emoji-picker/index.tsx index 2b0dd034..4cad7e32 100644 --- a/packages/client/src/components/emoji-picker/index.tsx +++ b/packages/client/src/components/emoji-picker/index.tsx @@ -1,4 +1,4 @@ -import { Popover, SideSheet, Typography } from '@douyinfe/semi-ui'; +import { Button, Popover, SideSheet, Typography } from '@douyinfe/semi-ui'; import { createKeysLocalStorageLRUCache } from 'helpers/lru-cache'; import { IsOnMobile } from 'hooks/use-on-mobile'; import { useToggle } from 'hooks/use-toggle'; @@ -60,9 +60,14 @@ export const EmojiPicker: React.FC = ({ onSelectEmoji, children }) => { [onSelectEmoji] ); + const clear = useCallback(() => { + onSelectEmoji(''); + }, [onSelectEmoji]); + const content = useMemo( () => (
+ {renderedList.map((item, index) => { return (
@@ -81,7 +86,7 @@ export const EmojiPicker: React.FC = ({ onSelectEmoji, children }) => { })}
), - [isMobile, renderedList, selectEmoji] + [isMobile, renderedList, selectEmoji, clear] ); useEffect(() => { diff --git a/packages/client/src/components/image-uploader/index.module.scss b/packages/client/src/components/image-uploader/index.module.scss new file mode 100644 index 00000000..67a83b09 --- /dev/null +++ b/packages/client/src/components/image-uploader/index.module.scss @@ -0,0 +1,20 @@ +.imgItem { + width: 100%; + height: 60px; + cursor: pointer; + border-radius: 0.25rem; + object-fit: cover; +} + +.uploadWrap { + display: flex; + flex-direction: column; + align-items: center; + + .bigImgItem { + max-height: 200px; + margin: 12px auto; + border-radius: 0.25rem; + object-fit: cover; + } +} diff --git a/packages/client/src/components/image-uploader/index.tsx b/packages/client/src/components/image-uploader/index.tsx new file mode 100644 index 00000000..419dc1da --- /dev/null +++ b/packages/client/src/components/image-uploader/index.tsx @@ -0,0 +1,155 @@ +import { Button, ButtonGroup, Col, Popover, Row, SideSheet, Skeleton, Space, TabPane, Tabs } from '@douyinfe/semi-ui'; +import { Upload } from 'components/upload'; +import { IsOnMobile } from 'hooks/use-on-mobile'; +import { useToggle } from 'hooks/use-toggle'; +import React, { useCallback, useMemo, useState } from 'react'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; + +import styles from './index.module.scss'; + +interface IProps { + images: Array<{ + key: string; + title: React.ReactNode; + images: string[]; + }>; + selectImage: (url: string) => void; +} + +const UploadTab = ({ selectImage }) => { + const [cover, setCover] = useState(''); + + const prevent = useCallback((e) => { + e.stopPropagation(); + }, []); + + const confirm = useCallback(() => { + selectImage(cover); + }, [cover, selectImage]); + + const clear = useCallback(() => { + setCover(''); + }, []); + + return ( +
+ + + {cover ? ( + + + + + ) : null} + + {cover ? : null} +
+ ); +}; + +export const ImageUploader: React.FC = ({ images, selectImage, children }) => { + const { isMobile } = IsOnMobile.useHook(); + const [visible, toggleVisible] = useToggle(false); + + const setImage = useCallback( + (url) => { + return () => selectImage(url); + }, + [selectImage] + ); + + const clear = useCallback(() => { + selectImage(''); + }, [selectImage]); + + const imageTabs = useMemo( + () => + images.map((image) => { + return ( + + + {image.images.map((url) => { + return ( + + } + /> + } + onClick={setImage(url)} + /> + + ); + })} + + + ); + }), + [images, setImage] + ); + + const content = useMemo( + () => ( +
+ + 清除 + + } + > + {imageTabs} + + selectImage(url)} /> + + +
+ ), + [isMobile, imageTabs, selectImage, clear] + ); + + return ( + + {isMobile ? ( + <> + + {content} + + toggleVisible(true)}>{children} + + ) : ( + {content}
} + > + {children} + + )} + + ); +}; diff --git a/packages/client/src/components/upload/index.tsx b/packages/client/src/components/upload/index.tsx index 5901a744..58653092 100644 --- a/packages/client/src/components/upload/index.tsx +++ b/packages/client/src/components/upload/index.tsx @@ -27,7 +27,11 @@ export const Upload: React.FC = ({ onOK, accept, style = {}, children }) beforeUpload={beforeUpload} previewFile={() => null} fileList={[]} - style={style} + style={{ + display: 'flex', + justifyContent: 'center', + ...style, + }} action={''} accept={accept} > diff --git a/packages/client/src/tiptap/core/extensions/title.tsx b/packages/client/src/tiptap/core/extensions/title.tsx index c3b139d8..c0712d51 100644 --- a/packages/client/src/tiptap/core/extensions/title.tsx +++ b/packages/client/src/tiptap/core/extensions/title.tsx @@ -1,6 +1,9 @@ import { mergeAttributes, Node } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; -import { isInTitle } from 'tiptap/prose-utils'; +import { getDatasetAttribute, isInTitle } from 'tiptap/prose-utils'; + +import { TitleWrapper } from '../wrappers/title'; export interface TitleOptions { HTMLAttributes: Record; @@ -27,16 +30,29 @@ export const Title = Node.create({ }; }, + addAttributes() { + return { + cover: { + default: '', + parseHTML: getDatasetAttribute('cover'), + }, + }; + }, + parseHTML() { return [ { - tag: 'p[class=title]', + tag: 'div[class=title]', }, ]; }, renderHTML({ HTMLAttributes }) { - return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(TitleWrapper); }, addProseMirrorPlugins() { diff --git a/packages/client/src/tiptap/core/wrappers/title/index.module.scss b/packages/client/src/tiptap/core/wrappers/title/index.module.scss new file mode 100644 index 00000000..ff9dd1da --- /dev/null +++ b/packages/client/src/tiptap/core/wrappers/title/index.module.scss @@ -0,0 +1,39 @@ +.wrap { + .coverWrap { + position: relative; + height: 280px; + margin-bottom: 12px; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-position: center 50%; + object-fit: cover; + } + + .toolbar { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 2; + font-size: 1rem; + } + } + + .emoji { + display: inline-block; + width: 78px; + height: 78px; + font-size: 78px; + font-weight: normal; + line-height: 78px; + cursor: pointer; + + &:hover, + &:focus, + &:focus-within { + background-color: var(--semi-color-fill-1); + } + } +} diff --git a/packages/client/src/tiptap/core/wrappers/title/index.tsx b/packages/client/src/tiptap/core/wrappers/title/index.tsx new file mode 100644 index 00000000..335d30a8 --- /dev/null +++ b/packages/client/src/tiptap/core/wrappers/title/index.tsx @@ -0,0 +1,67 @@ +import { Button, ButtonGroup } from '@douyinfe/semi-ui'; +import { DOCUMENT_COVERS } from '@think/constants'; +import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'; +import cls from 'classnames'; +import { ImageUploader } from 'components/image-uploader'; +import { useCallback } from 'react'; + +import styles from './index.module.scss'; + +const images = [ + { + key: 'placeholers', + title: '图库', + images: DOCUMENT_COVERS, + }, +]; + +export const TitleWrapper = ({ editor, node }) => { + const isEditable = editor.isEditable; + const { cover } = node.attrs; + + const setCover = useCallback( + (cover) => { + editor.commands.command(({ tr }) => { + const pos = 0; + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + cover, + }); + tr.setMeta('scrollIntoView', false); + return true; + }); + }, + [editor, node] + ); + + const addRandomCover = useCallback(() => { + setCover(DOCUMENT_COVERS[~~(Math.random() * DOCUMENT_COVERS.length)]); + }, [setCover]); + + return ( + + {cover ? ( +
+ cover + {isEditable ? ( +
+ + + +
+ ) : null} +
+ ) : null} + {isEditable && !cover ? ( +
+ + {!cover ? : null} + +
+ ) : null} + +
+ ); +}; diff --git a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/nodes/title.ts b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/nodes/title.ts index 5b1dba65..9d451f8d 100644 --- a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/nodes/title.ts +++ b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/nodes/title.ts @@ -4,6 +4,6 @@ export class Title extends Node { type = 'title'; matching() { - return this.DOMNode.nodeName === 'P' && this.DOMNode.classList.contains('title'); + return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('title'); } } diff --git a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/markdown-to-html/index.ts b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/markdown-to-html/index.ts index 8e6b9150..23987085 100644 --- a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/markdown-to-html/index.ts +++ b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/markdown-to-html/index.ts @@ -23,6 +23,7 @@ const markdownMention = createMarkdownContainer('mention'); const markdownMind = createMarkdownContainer('mind'); const markdownFlow = createMarkdownContainer('flow'); const markdownTableOfContents = createMarkdownContainer('tableOfContents'); +const markdownTitle = createMarkdownContainer('title'); const markdown = markdownit('commonmark') .enable('strikethrough') @@ -46,7 +47,8 @@ const markdown = markdownit('commonmark') .use(markdownDocumentReference) .use(markdownDocumentChildren) .use(markdownFlow) - .use(markdownTableOfContents); + .use(markdownTableOfContents) + .use(markdownTitle); export const markdownToHTML = (rawMarkdown) => { return sanitize(markdown.render(rawMarkdown), {}); diff --git a/packages/client/src/tiptap/markdown/prosemirror-to-markdown/index.ts b/packages/client/src/tiptap/markdown/prosemirror-to-markdown/index.ts index d1b9f47c..3232c272 100644 --- a/packages/client/src/tiptap/markdown/prosemirror-to-markdown/index.ts +++ b/packages/client/src/tiptap/markdown/prosemirror-to-markdown/index.ts @@ -158,7 +158,7 @@ const SerializerConfig = { state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' '); }, [Text.name]: defaultMarkdownSerializer.nodes.text, - [Title.name]: renderHTMLNode('p', false, true, { class: 'title' }), + [Title.name]: renderCustomContainer('title'), }, }; diff --git a/packages/constants/lib/index.d.ts b/packages/constants/lib/index.d.ts index bb08b90c..b44762c0 100644 --- a/packages/constants/lib/index.d.ts +++ b/packages/constants/lib/index.d.ts @@ -5,3 +5,4 @@ export declare const EMPTY_DOCUMNENT: { content: string; state: Buffer; }; +export declare const DOCUMENT_COVERS: string[]; diff --git a/packages/constants/lib/index.js b/packages/constants/lib/index.js index a269a6c3..0a197148 100644 --- a/packages/constants/lib/index.js +++ b/packages/constants/lib/index.js @@ -1,6 +1,6 @@ "use strict"; exports.__esModule = true; -exports.EMPTY_DOCUMNENT = exports.WIKI_AVATARS = exports.DEFAULT_WIKI_AVATAR = void 0; +exports.DOCUMENT_COVERS = exports.EMPTY_DOCUMNENT = exports.WIKI_AVATARS = exports.DEFAULT_WIKI_AVATAR = void 0; exports.DEFAULT_WIKI_AVATAR = 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default0-96.png'; exports.WIKI_AVATARS = [ exports.DEFAULT_WIKI_AVATAR, @@ -34,3 +34,16 @@ exports.EMPTY_DOCUMNENT = { 3, 15, 6, 23, 5, ])) }; +exports.DOCUMENT_COVERS = [ + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default2-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default7-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default8-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default14-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default21-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default23-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default1-96%20(1).png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default4-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default12-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default17-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default18-96.png', +]; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 96c73493..3e76a7cc 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -35,3 +35,17 @@ export const EMPTY_DOCUMNENT = { ]) ), }; + +export const DOCUMENT_COVERS = [ + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default2-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default7-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default8-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default14-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default21-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default23-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default1-96%20(1).png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default4-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default12-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default17-96.png', + 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default18-96.png', +];