client: now we can insert cover in title

pull/64/head
fantasticit 2022-06-02 13:42:32 +08:00
parent 4ce3e03058
commit 051f8ec3f0
13 changed files with 346 additions and 10 deletions

View File

@ -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 { createKeysLocalStorageLRUCache } from 'helpers/lru-cache';
import { IsOnMobile } from 'hooks/use-on-mobile'; import { IsOnMobile } from 'hooks/use-on-mobile';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
@ -60,9 +60,14 @@ export const EmojiPicker: React.FC<IProps> = ({ onSelectEmoji, children }) => {
[onSelectEmoji] [onSelectEmoji]
); );
const clear = useCallback(() => {
onSelectEmoji('');
}, [onSelectEmoji]);
const content = useMemo( const content = useMemo(
() => ( () => (
<div className={styles.wrap} style={{ padding: isMobile ? '24px 0' : 0 }}> <div className={styles.wrap} style={{ padding: isMobile ? '24px 0' : 0 }}>
<Button onClick={clear}></Button>
{renderedList.map((item, index) => { {renderedList.map((item, index) => {
return ( return (
<div key={item.title}> <div key={item.title}>
@ -81,7 +86,7 @@ export const EmojiPicker: React.FC<IProps> = ({ onSelectEmoji, children }) => {
})} })}
</div> </div>
), ),
[isMobile, renderedList, selectEmoji] [isMobile, renderedList, selectEmoji, clear]
); );
useEffect(() => { useEffect(() => {

View File

@ -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;
}
}

View File

@ -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 (
<div className={styles.uploadWrap} onClick={prevent}>
<Space>
<Upload onOK={setCover}></Upload>
{cover ? (
<ButtonGroup>
<Button theme="solid" onClick={confirm}>
</Button>
<Button theme="solid" onClick={clear}>
</Button>
</ButtonGroup>
) : null}
</Space>
{cover ? <img src={cover} className={styles.bigImgItem} /> : null}
</div>
);
};
export const ImageUploader: React.FC<IProps> = ({ 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 (
<TabPane key={image.key} tab={image.title} itemKey={image.key}>
<Row gutter={6}>
{image.images.map((url) => {
return (
<Col span={6} key={url}>
<LazyLoadImage
className={styles.imgItem}
src={url}
delayTime={300}
placeholder={
<Skeleton
loading
placeholder={<Skeleton.Image className={styles.imgItem} style={{ height: 60 }} />}
/>
}
onClick={setImage(url)}
/>
</Col>
);
})}
</Row>
</TabPane>
);
}),
[images, setImage]
);
const content = useMemo(
() => (
<div className={styles.wrap} style={{ padding: isMobile ? '24px 0' : 0 }}>
<Tabs
size="small"
lazyRender
keepDOM
tabBarExtraContent={
<Button size="small" onClick={clear}>
</Button>
}
>
{imageTabs}
<TabPane tab="上传" itemKey="upload" style={{ textAlign: 'center' }}>
<UploadTab selectImage={(url) => selectImage(url)} />
</TabPane>
</Tabs>
</div>
),
[isMobile, imageTabs, selectImage, clear]
);
return (
<span>
{isMobile ? (
<>
<SideSheet
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
placement="bottom"
title={'图片'}
visible={visible}
onCancel={toggleVisible}
height={370}
mask={false}
>
{content}
</SideSheet>
<span onMouseDown={() => toggleVisible(true)}>{children}</span>
</>
) : (
<Popover
showArrow
zIndex={10000}
trigger="click"
position="bottomLeft"
visible={visible}
onVisibleChange={toggleVisible}
content={<div style={{ width: 360, maxWidth: '96vw' }}>{content}</div>}
>
{children}
</Popover>
)}
</span>
);
};

View File

@ -27,7 +27,11 @@ export const Upload: React.FC<IProps> = ({ onOK, accept, style = {}, children })
beforeUpload={beforeUpload} beforeUpload={beforeUpload}
previewFile={() => null} previewFile={() => null}
fileList={[]} fileList={[]}
style={style} style={{
display: 'flex',
justifyContent: 'center',
...style,
}}
action={''} action={''}
accept={accept} accept={accept}
> >

View File

@ -1,6 +1,9 @@
import { mergeAttributes, Node } from '@tiptap/core'; import { mergeAttributes, Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; 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 { export interface TitleOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@ -27,16 +30,29 @@ export const Title = Node.create<TitleOptions>({
}; };
}, },
addAttributes() {
return {
cover: {
default: '',
parseHTML: getDatasetAttribute('cover'),
},
};
},
parseHTML() { parseHTML() {
return [ return [
{ {
tag: 'p[class=title]', tag: 'div[class=title]',
}, },
]; ];
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(TitleWrapper);
}, },
addProseMirrorPlugins() { addProseMirrorPlugins() {

View File

@ -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);
}
}
}

View File

@ -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 (
<NodeViewWrapper className={cls(styles.wrap, 'title')}>
{cover ? (
<div className={styles.coverWrap} contentEditable={false}>
<img src={cover} alt="cover" />
{isEditable ? (
<div className={styles.toolbar}>
<ImageUploader images={images} selectImage={setCover}>
<Button size="small" theme="solid" type="tertiary">
</Button>
</ImageUploader>
</div>
) : null}
</div>
) : null}
{isEditable && !cover ? (
<div className={styles.emptyToolbarWrap}>
<ButtonGroup size={'small'} theme="light" type="tertiary">
{!cover ? <Button onClick={addRandomCover}></Button> : null}
</ButtonGroup>
</div>
) : null}
<NodeViewContent></NodeViewContent>
</NodeViewWrapper>
);
};

View File

@ -4,6 +4,6 @@ export class Title extends Node {
type = 'title'; type = 'title';
matching() { matching() {
return this.DOMNode.nodeName === 'P' && this.DOMNode.classList.contains('title'); return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('title');
} }
} }

View File

@ -23,6 +23,7 @@ const markdownMention = createMarkdownContainer('mention');
const markdownMind = createMarkdownContainer('mind'); const markdownMind = createMarkdownContainer('mind');
const markdownFlow = createMarkdownContainer('flow'); const markdownFlow = createMarkdownContainer('flow');
const markdownTableOfContents = createMarkdownContainer('tableOfContents'); const markdownTableOfContents = createMarkdownContainer('tableOfContents');
const markdownTitle = createMarkdownContainer('title');
const markdown = markdownit('commonmark') const markdown = markdownit('commonmark')
.enable('strikethrough') .enable('strikethrough')
@ -46,7 +47,8 @@ const markdown = markdownit('commonmark')
.use(markdownDocumentReference) .use(markdownDocumentReference)
.use(markdownDocumentChildren) .use(markdownDocumentChildren)
.use(markdownFlow) .use(markdownFlow)
.use(markdownTableOfContents); .use(markdownTableOfContents)
.use(markdownTitle);
export const markdownToHTML = (rawMarkdown) => { export const markdownToHTML = (rawMarkdown) => {
return sanitize(markdown.render(rawMarkdown), {}); return sanitize(markdown.render(rawMarkdown), {});

View File

@ -158,7 +158,7 @@ const SerializerConfig = {
state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' '); state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' ');
}, },
[Text.name]: defaultMarkdownSerializer.nodes.text, [Text.name]: defaultMarkdownSerializer.nodes.text,
[Title.name]: renderHTMLNode('p', false, true, { class: 'title' }), [Title.name]: renderCustomContainer('title'),
}, },
}; };

View File

@ -5,3 +5,4 @@ export declare const EMPTY_DOCUMNENT: {
content: string; content: string;
state: Buffer; state: Buffer;
}; };
export declare const DOCUMENT_COVERS: string[];

View File

@ -1,6 +1,6 @@
"use strict"; "use strict";
exports.__esModule = true; 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.DEFAULT_WIKI_AVATAR = 'https://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-01/default0-96.png';
exports.WIKI_AVATARS = [ exports.WIKI_AVATARS = [
exports.DEFAULT_WIKI_AVATAR, exports.DEFAULT_WIKI_AVATAR,
@ -34,3 +34,16 @@ exports.EMPTY_DOCUMNENT = {
3, 15, 6, 23, 5, 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',
];

View File

@ -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',
];