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 { IsOnMobile } from 'hooks/use-on-mobile';
|
||||
import { useToggle } from 'hooks/use-toggle';
|
||||
|
@ -60,9 +60,14 @@ export const EmojiPicker: React.FC<IProps> = ({ onSelectEmoji, children }) => {
|
|||
[onSelectEmoji]
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
onSelectEmoji('');
|
||||
}, [onSelectEmoji]);
|
||||
|
||||
const content = useMemo(
|
||||
() => (
|
||||
<div className={styles.wrap} style={{ padding: isMobile ? '24px 0' : 0 }}>
|
||||
<Button onClick={clear}>清除</Button>
|
||||
{renderedList.map((item, index) => {
|
||||
return (
|
||||
<div key={item.title}>
|
||||
|
@ -81,7 +86,7 @@ export const EmojiPicker: React.FC<IProps> = ({ onSelectEmoji, children }) => {
|
|||
})}
|
||||
</div>
|
||||
),
|
||||
[isMobile, renderedList, selectEmoji]
|
||||
[isMobile, renderedList, selectEmoji, clear]
|
||||
);
|
||||
|
||||
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}
|
||||
previewFile={() => null}
|
||||
fileList={[]}
|
||||
style={style}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
...style,
|
||||
}}
|
||||
action={''}
|
||||
accept={accept}
|
||||
>
|
||||
|
|
|
@ -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<string, any>;
|
||||
|
@ -27,16 +30,29 @@ export const Title = Node.create<TitleOptions>({
|
|||
};
|
||||
},
|
||||
|
||||
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() {
|
||||
|
|
|
@ -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';
|
||||
|
||||
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 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), {});
|
||||
|
|
|
@ -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'),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -5,3 +5,4 @@ export declare const EMPTY_DOCUMNENT: {
|
|||
content: string;
|
||||
state: Buffer;
|
||||
};
|
||||
export declare const DOCUMENT_COVERS: string[];
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
|
|
@ -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