mirror of https://github.com/fantasticit/think.git
client: now we can insert cover in title
This commit is contained in:
parent
4ce3e03058
commit
051f8ec3f0
|
@ -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(() => {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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), {});
|
||||||
|
|
|
@ -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'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -5,3 +5,4 @@ export declare const EMPTY_DOCUMNENT: {
|
||||||
content: string;
|
content: string;
|
||||||
state: Buffer;
|
state: Buffer;
|
||||||
};
|
};
|
||||||
|
export declare const DOCUMENT_COVERS: string[];
|
||||||
|
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
Loading…
Reference in New Issue