274 lines
13 KiB
JavaScript
274 lines
13 KiB
JavaScript
import React, { useState, useEffect } from "react";
|
||
import { Alert, Button, Row, Col, Space, Input, Tooltip, } from "antd";
|
||
import "@wangeditor/editor/dist/css/style.css"; // 引入 css
|
||
import { Editor, Toolbar } from "@wangeditor/editor-for-react";
|
||
import { SlateNode } from "@wangeditor/editor";
|
||
import { generateNewId } from "oak-domain/lib/utils/uuid";
|
||
import classNames from "classnames";
|
||
import Prompt from "../../../components/common/prompt";
|
||
import Style from "./web.module.less";
|
||
import { CloseOutlined, EyeOutlined, MenuOutlined, CaretDownOutlined } from "@ant-design/icons";
|
||
// 工具栏配置
|
||
const toolbarConfig = {
|
||
excludeKeys: ["fullScreen"],
|
||
}; // TS 语法
|
||
// 自定义校验图片
|
||
function customCheckImageFn(src, alt, url) {
|
||
// TS 语法
|
||
if (!src) {
|
||
return;
|
||
}
|
||
if (src.indexOf("http") !== 0) {
|
||
return "图片网址必须以 http/https 开头";
|
||
}
|
||
return true;
|
||
// 返回值有三种选择:
|
||
// 1. 返回 true ,说明检查通过,编辑器将正常插入图片
|
||
// 2. 返回一个字符串,说明检查未通过,编辑器会阻止插入。会 alert 出错误信息(即返回的字符串)
|
||
// 3. 返回 undefined(即没有任何返回),说明检查未通过,编辑器会阻止插入。但不会提示任何信息
|
||
}
|
||
function TocView(props) {
|
||
const { toc, showToc, tocPosition, setShowToc, highlightBgColor, scrollId } = props;
|
||
useEffect(() => {
|
||
document.documentElement.style.setProperty('--highlight-bg-color', highlightBgColor);
|
||
}, [highlightBgColor]);
|
||
const generateTocList = (items, currentLevel = 1, parentId = null) => {
|
||
//递归生成嵌套列表
|
||
const result = [];
|
||
let lastId = parentId;
|
||
while (items.length > 0) {
|
||
const item = items[0];
|
||
//有无子级标题,有则显示展开图标
|
||
const hasChildren = items.length > 1 && items[1].level > item.level;
|
||
if (item.level > currentLevel) {
|
||
// 递归生成子列表
|
||
const sublist = generateTocList(items, item.level, item.id);
|
||
result.push(<li key={item.id} id={`ul-${lastId}`}>
|
||
<ul style={{ listStyleType: 'none', paddingInlineStart: '6px' }}>
|
||
{sublist}
|
||
</ul>
|
||
</li>);
|
||
}
|
||
else if (item.level < currentLevel) {
|
||
// 结束当前层级
|
||
break;
|
||
}
|
||
else {
|
||
// 添加当前层级的 <li>
|
||
result.push(<li className={Style.listItem} key={item.id} id={`li-${item.id}`} style={{ paddingLeft: `${(item.level - 1) * 6}px`, }}>
|
||
<CaretDownOutlined id={`icon-${item.id}`} className={Style.icon} style={{ visibility: hasChildren && item.level < 5 ? 'visible' : 'hidden', color: '#A5A5A5' }} onClick={() => {
|
||
const iconElem = document.getElementById(`icon-${item.id}`);
|
||
const ulElem = document.getElementById(`ul-${item.id}`);
|
||
const isFolded = iconElem?.className.includes('iconFold') || ulElem?.className.includes('fold');
|
||
if (isFolded) {
|
||
iconElem?.classList.remove(Style.iconFold);
|
||
ulElem?.classList.remove(Style.fold);
|
||
}
|
||
else {
|
||
iconElem?.classList.add(Style.iconFold);
|
||
ulElem?.classList.add(Style.fold);
|
||
}
|
||
}}/>
|
||
<div style={{ fontSize: '1em', fontWeight: item.level === 1 ? 'bold' : 'normal' }} className={Style.tocItem} onClick={(event) => {
|
||
// editor.scrollToElem(item.id);
|
||
//编辑器滚动到对应元素
|
||
const elem = document.getElementById(item.id);
|
||
const elemTop = elem?.getBoundingClientRect().top;
|
||
const scrollContainer = document.getElementById(scrollId || 'article-upsert-editorContainer');
|
||
const containerTop = scrollContainer?.getBoundingClientRect().top;
|
||
scrollContainer?.scrollBy({
|
||
top: elemTop - containerTop,
|
||
behavior: 'smooth',
|
||
});
|
||
//添加背景色
|
||
elem?.classList.add(Style.highlight);
|
||
//移除背景色类名
|
||
setTimeout(function () {
|
||
elem?.classList.remove(Style.highlight);
|
||
}, 1000);
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}}>
|
||
{item.text}
|
||
</div>
|
||
</li>);
|
||
items.shift(); // 移除已处理的项
|
||
lastId = item.id;
|
||
}
|
||
}
|
||
return result;
|
||
};
|
||
return (<div className={classNames(Style.tocContainer, {
|
||
// [Style.fixed]: fixed
|
||
})}>
|
||
{showToc ? (<>
|
||
<div className={Style.catalogTitle}>
|
||
<div style={{ color: '#A5A5A5' }}>大纲</div>
|
||
<CloseOutlined style={{ color: '#A5A5A5' }} onClick={() => setShowToc(false)}/>
|
||
</div>
|
||
|
||
{(toc && toc.length > 0) ? (<ul style={{ listStyleType: 'none', paddingInlineStart: '0px' }}>{generateTocList([...toc])}</ul>) : (<div style={{ display: 'flex', alignItems: 'center', color: '#B1B1B1', height: '200px' }}>
|
||
<div>
|
||
对文档内容应用“标题”样式,即可生成大纲
|
||
</div>
|
||
</div>)}
|
||
</>) : (<div className={classNames(Style.tocButton, { [Style.tocButtonRight]: tocPosition === 'right' })}>
|
||
<Tooltip title="显示大纲" placement={tocPosition === 'right' ? 'left' : 'right'}>
|
||
<Button size="small" icon={<MenuOutlined />} onClick={() => setShowToc(true)}/>
|
||
</Tooltip>
|
||
</div>)}
|
||
</div>);
|
||
}
|
||
export default function Render(props) {
|
||
const { methods, data } = props;
|
||
const { t, setEditor, check, uploadFile, update, setHtml, gotoPreview, clearContentTip, } = methods;
|
||
const { id, content, editor, origin = 'qiniu', oakFullpath, html, tocPosition = 'none', highlightBgColor = 'none', scrollId } = data;
|
||
const [articleId, setArticleId] = useState('');
|
||
const [toc, setToc] = useState([]);
|
||
const [showToc, setShowToc] = useState(false);
|
||
useEffect(() => {
|
||
if (id) {
|
||
setArticleId(id);
|
||
}
|
||
}, [id]);
|
||
useEffect(() => {
|
||
if (tocPosition !== 'none') {
|
||
setShowToc(true);
|
||
}
|
||
}, [tocPosition]);
|
||
return (<div className={Style.container}>
|
||
<Prompt when={!id || data.oakDirty} message={'您确认离开页面吗?'}/>
|
||
<div style={{ width: 'calc(100% - 16px)', }}>
|
||
<Toolbar editor={editor} defaultConfig={toolbarConfig} mode="default"/>
|
||
</div>
|
||
<Row gutter={[16, 0]}>
|
||
{tocPosition === 'left' ? (<Col flex="228px">
|
||
<TocView toc={toc} showToc={showToc} tocPosition='left' setShowToc={setShowToc} highlightBgColor={highlightBgColor} scrollId={scrollId}/>
|
||
</Col>) : null}
|
||
|
||
<Col flex="auto">
|
||
<div className={Style.content}>
|
||
<div id="article-upsert-editorContainer" className={classNames(Style.editorContainer, {
|
||
[Style.editorExternalContainer]: !!scrollId
|
||
})}>
|
||
{data.contentTip && (<Alert type="info" message={t('tips.content')} closable onClose={() => clearContentTip()}/>)}
|
||
<div className={Style.titleContainer}>
|
||
<Input onChange={(e) => update({ name: e.target.value })} value={data.name} placeholder={'请输入文章标题'} size="large" maxLength={32} suffix={`${[...(data.name || '')].length}/32`} className={Style.titleInput}/>
|
||
</div>
|
||
<div className={Style.editorContent}>
|
||
<Editor defaultConfig={{
|
||
autoFocus: true,
|
||
placeholder: '请输入文章内容...',
|
||
MENU_CONF: {
|
||
checkImage: customCheckImageFn,
|
||
uploadImage: {
|
||
// 自定义上传
|
||
async customUpload(file, insertFn) {
|
||
// TS 语法
|
||
// file 即选中的文件
|
||
const { name, size, type } = file;
|
||
const extension = name.substring(name.lastIndexOf('.') + 1);
|
||
const filename = name.substring(0, name.lastIndexOf('.'));
|
||
const extraFile = {
|
||
entity: 'article',
|
||
entityId: articleId,
|
||
origin: origin,
|
||
type: 'image',
|
||
tag1: 'source',
|
||
objectId: generateNewId(),
|
||
filename,
|
||
size,
|
||
extension,
|
||
bucket: '',
|
||
id: generateNewId(),
|
||
fileType: type
|
||
};
|
||
try {
|
||
// 自己实现上传,并得到图片 url alt href
|
||
const url = await uploadFile(extraFile, file);
|
||
// 最后插入图片
|
||
insertFn(url, extraFile.filename);
|
||
}
|
||
catch (err) { }
|
||
},
|
||
},
|
||
uploadVideo: {
|
||
// 自定义上传
|
||
async customUpload(file, insertFn) {
|
||
// TS 语法
|
||
// file 即选中的文件
|
||
const { name, size, type } = file;
|
||
const extension = name.substring(name.lastIndexOf('.') + 1);
|
||
const filename = name.substring(0, name.lastIndexOf('.'));
|
||
const extraFile = {
|
||
entity: 'article',
|
||
entityId: articleId,
|
||
origin: origin,
|
||
type: 'video',
|
||
tag1: 'source',
|
||
objectId: generateNewId(),
|
||
filename,
|
||
size,
|
||
extension,
|
||
bucket: '',
|
||
id: generateNewId(),
|
||
fileType: type
|
||
};
|
||
try {
|
||
// 自己实现上传,并得到图片 url alt href
|
||
const url = await uploadFile(extraFile, file);
|
||
// 最后插入图片
|
||
insertFn(url, url + '?vframe/jpg/offset/0');
|
||
}
|
||
catch (err) { }
|
||
},
|
||
},
|
||
},
|
||
}} onCreated={setEditor} onChange={(editor) => {
|
||
setHtml(editor.getHtml());
|
||
const headers = editor.getElemsByTypePrefix('header');
|
||
const tocItems = headers.map((header) => {
|
||
const text = SlateNode.string(header);
|
||
const { id, type } = header;
|
||
return {
|
||
text,
|
||
level: parseInt(type.substring(6)),
|
||
id,
|
||
};
|
||
});
|
||
setToc([...tocItems]);
|
||
}} style={{
|
||
minHeight: 440,
|
||
}} mode="default"/>
|
||
</div>
|
||
|
||
</div>
|
||
<div className={Style.footer}>
|
||
<Row align="middle">
|
||
<Col flex="none">
|
||
<Space>
|
||
<Button disabled={!data.oakDirty ||
|
||
data.oakExecuting} type="primary" onClick={() => {
|
||
check();
|
||
}}>
|
||
保存
|
||
</Button>
|
||
<Button onClick={() => {
|
||
gotoPreview(content, data.name);
|
||
}}>
|
||
<EyeOutlined />
|
||
预览
|
||
</Button>
|
||
</Space>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
</div>
|
||
</Col>
|
||
{tocPosition === 'right' ? (<Col flex="228px">
|
||
<TocView toc={toc} showToc={showToc} tocPosition='right' setShowToc={setShowToc} highlightBgColor={highlightBgColor} scrollId={scrollId}/>
|
||
</Col>) : null}
|
||
</Row>
|
||
</div>);
|
||
}
|