init
This commit is contained in:
commit
738d23ca9d
14
.eslintrc.cjs
Normal file
14
.eslintrc.cjs
Normal file
@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
},
|
||||
}
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
15
index.html
Normal file
15
index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:undefined;base64,AAABAAEAEBAAAAEAGABIAwAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAAFzUkdCAK7OHOkAAAAEZ0FNQQAAsY8L/GEFAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAC3UlEQVQ4T32TS0xTQRSGLyggKC20lErb2/IoV1duNGxckPCoghg0go9EBVmYaIKCjwTdoIga8AkuBBSjIQoqMdGoC42oIQoaRRZqjCFGDWqM+KhQW1vv/zsXWiS+bnKSuTPn/+Y/M2ckacKnNHJmegO2pO3H+bTd6EutxkBKlYgK9KWs4zlHGTalrIIyUTM6Tm/DgvQm3HHWkskVZGIDqO8IMLbdT90JH42HfLRsCzB5DelYTtoXo9uah7wx8SmcdR4mUzeTSeVk5GUwousHo674Gd3pezetzTuoa/EgvnGEploP5VKV9kWkPF9ENtolpQ0fnQfETw0ZfldEDzj5pnoy6qo3LWTV0OKxxTeMHDHWfUVi9VfKxeoYIAdDktKOIQ0QfY2U+siwXnWXJrTlINOWixY5F63WHORrc4a64Y2mGjeTNoyEHLyXlIsYcDQJ8SMRD9XnEjlJiEpkFyC7xC7BsLmwWZIYZtrpvmfe/oXyQuEiW+Qrt9Fl7ggC+lCnJYmd304Uj40x4sjkFNMO91YNYFv2nbYsXJdmvsZhU2cQ8ABbtaTfdw/BLC7IAlCiAawrfBrggKQEUGjpHnfQqtUqSrj/uwNRwgupmuEJO917Rh0U+WnPRoHkIKekf8ansH7tEOGW+pFozcIs4WLwFwRD9lzMNe/DVHEGg+YqN+V89aNzPqJGb2qGij3GweAt9Ki3pF7okgoYY3ehQJ6HQpGo0/KMdR9iBeCmZa2Xtjy1drwjU0m94sWb6MfBPugKvIq87K+MPuvJiDntn61r9qyPaxh+aqgfrnCWI0pegvoQdByifEZW2kv49b2/OjGm8ztFJ1J37BtFI9GwxUf9Uuz94y2EJpzPfhQn98BnvUQazqjUHw9Qf9DPOPEO4laCcUXwCsCSfwK0hZTbyJAv4ImlhTTvJhMqBaxU1FiMx7rlmPNf8fhiMyOmH8XqxFrcSKjEDWMZVokrnPw38U+G1eMN2LWiFwAAAABJRU5ErkJggg==" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>协作-智能AI写作、文档纠错校对、文本图像合规检测平台</title>
|
||||
<meta name="keywords" content="文本纠错,文本校对,编辑校对,文本改写,图片合规检查,文本合规检查,音频合规检查,视频合规检查,文章生成,违禁拦截,质检服务,AI质检">
|
||||
<meta name="description" content="讯飞智检,支持对纯文本、Word、图片、音频、视频进行批量审查,实现文本校对,并有效识别涉政、违禁、色情、暴恐、辱骂、广告导流等风险内容,节省人工审核成本,提升数据安全性。">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
50
package.json
Normal file
50
package.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "editor",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.11.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@slate-yjs/core": "^1.0.0",
|
||||
"@slate-yjs/react": "^1.0.0",
|
||||
"@textbus/collaborate": "^3.3.3",
|
||||
"@textbus/core": "^3.3.3",
|
||||
"@textbus/platform-browser": "^3.3.3",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-react": "^1.0.6",
|
||||
"ahooks": "^3.7.7",
|
||||
"antd": "^5.5.1",
|
||||
"classnames": "^2.3.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"slate": "^0.94.1",
|
||||
"slate-react": "^0.94.2",
|
||||
"yjs": "^13.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||
"@typescript-eslint/parser": "^5.57.1",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.3.4",
|
||||
"less": "^4.1.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"snabbdom": "^3.5.1"
|
||||
}
|
||||
}
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
193
src/App.less
Normal file
193
src/App.less
Normal file
@ -0,0 +1,193 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.proofread-popover {
|
||||
--antd-arrow-background-color: #666;
|
||||
.ant-popover-inner{
|
||||
padding: 5px 10px;
|
||||
background-color: var(--antd-arrow-background-color);
|
||||
}
|
||||
.ant-popover-inner-content{
|
||||
color:#fff;
|
||||
}
|
||||
}
|
||||
|
||||
.page-example {
|
||||
}
|
||||
|
||||
.page-block-header {
|
||||
|
||||
}
|
||||
|
||||
.page-block-article {
|
||||
--box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
--box-radius: 10px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
|
||||
.editor-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.quill {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-container.ql-snow {
|
||||
border: none;
|
||||
}
|
||||
|
||||
#quill-editor-toolbar {
|
||||
border: none;
|
||||
box-shadow: var(--box-shadow);
|
||||
margin: 20px;
|
||||
border-radius: var(--box-radius);
|
||||
}
|
||||
}
|
||||
|
||||
.page-block-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-block-control {
|
||||
width: 400px;
|
||||
padding: 20px;
|
||||
|
||||
.replace-content-container {
|
||||
box-shadow: var(--box-shadow);
|
||||
border-radius: var(--box-radius);
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.replace-content-container {
|
||||
padding: 15px;
|
||||
|
||||
.replace-item {
|
||||
box-shadow: var(--box-shadow);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 15px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s;
|
||||
outline: solid 2px transparent;
|
||||
|
||||
.data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(239, 243, 255, 0.83);
|
||||
}
|
||||
|
||||
&.replace-type-delete {
|
||||
.origin {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.delete-text {
|
||||
color: #f00;
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
// state
|
||||
&.replace-state-selected {
|
||||
background-color: rgba(239, 243, 255, 0.83);
|
||||
padding-bottom: 50px;
|
||||
outline: solid 2px #747bff;
|
||||
}
|
||||
|
||||
.origin {
|
||||
margin-left: 5px;
|
||||
color: #f00;
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.icon-arrow {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: bold;
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.action {
|
||||
span {
|
||||
font-size: 12px;
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
//减少动效媒体查询
|
||||
//https://www.cnblogs.com/xiaolantian/p/12701279.html
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
20
src/App.tsx
Normal file
20
src/App.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// import {SimpleSlateEditor} from "./components/SimpleSlateEditor.tsx";
|
||||
import '@/App.less'
|
||||
import {BrowserRouter, Navigate, Route, Routes} from "react-router-dom";
|
||||
import TextPage from "./pages/quill/text.tsx";
|
||||
import WangEditor from "./pages/wang/wang-editor.tsx";
|
||||
|
||||
|
||||
function App() {
|
||||
|
||||
return (<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/wang"/>}/>
|
||||
<Route path="/proofread" element={<TextPage />}/>
|
||||
<Route path="/wang" element={<WangEditor />}/>
|
||||
|
||||
</Routes>
|
||||
</BrowserRouter>)
|
||||
}
|
||||
|
||||
export default App
|
6
src/assets/arrow-right.svg
Normal file
6
src/assets/arrow-right.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg class="svg-icon icon-arrow icon-arrow-right" viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32" height="32">
|
||||
<path d="M64 552.013h836v64h-836z"></path>
|
||||
<path d="M768 408.987l192 207.026h-192z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 264 B |
5
src/assets/react.svg
Normal file
5
src/assets/react.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img"
|
||||
class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228">
|
||||
<path fill="#00D8FF"
|
||||
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 4.1 KiB |
29
src/components/Editor/components.tsx
Normal file
29
src/components/Editor/components.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import {ContentType, defineComponent, Injector} from "@textbus/core";
|
||||
import {ComponentLoader} from '@textbus/platform-browser'
|
||||
|
||||
export const editorRootComponent = defineComponent({
|
||||
name: 'editorRootComponent',
|
||||
type: ContentType.BlockComponent,
|
||||
setup(_) {
|
||||
return {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div>这是我的第一个组件</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
export const editorRootComponentLoader: ComponentLoader = {
|
||||
match(): boolean {
|
||||
return true;
|
||||
},
|
||||
read(_: HTMLElement, injector: Injector) {
|
||||
return editorRootComponent.createInstance(injector);
|
||||
}
|
||||
|
||||
}
|
5
src/components/Editor/index.tsx
Normal file
5
src/components/Editor/index.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import {Viewer} from "@textbus/platform-browser";
|
||||
import {editorRootComponent, editorRootComponentLoader} from "./components.tsx";
|
||||
|
||||
|
||||
export const Editor = new Viewer(editorRootComponent,editorRootComponentLoader);
|
196
src/components/EditorComponents.tsx
Normal file
196
src/components/EditorComponents.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import React, { Ref, PropsWithChildren } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { cx, css } from '@emotion/css'
|
||||
|
||||
interface BaseProps {
|
||||
className: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
type OrNull<T> = T | null
|
||||
|
||||
export const Button = React.forwardRef(
|
||||
(
|
||||
{
|
||||
className,
|
||||
active,
|
||||
reversed,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
{
|
||||
active: boolean
|
||||
reversed: boolean
|
||||
} & BaseProps
|
||||
>,
|
||||
ref: Ref<OrNull<HTMLSpanElement>>
|
||||
) => (
|
||||
<span
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
cursor: pointer;
|
||||
color: ${reversed
|
||||
? active
|
||||
? 'white'
|
||||
: '#aaa'
|
||||
: active
|
||||
? 'black'
|
||||
: '#ccc'};
|
||||
`
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
export const EditorValue = React.forwardRef(
|
||||
(
|
||||
{
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
{
|
||||
value: any
|
||||
} & BaseProps
|
||||
>,
|
||||
ref: Ref<OrNull<null>>
|
||||
) => {
|
||||
const textLines = value.document.nodes
|
||||
.map(node => node.text)
|
||||
.toArray()
|
||||
.join('\n')
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
margin: 30px -20px 0;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={css`
|
||||
font-size: 14px;
|
||||
padding: 5px 20px;
|
||||
color: #404040;
|
||||
border-top: 2px solid #eeeeee;
|
||||
background: #f8f8f8;
|
||||
`}
|
||||
>
|
||||
Slate's value as text
|
||||
</div>
|
||||
<div
|
||||
className={css`
|
||||
color: #404040;
|
||||
font: 12px monospace;
|
||||
white-space: pre-wrap;
|
||||
padding: 10px 20px;
|
||||
div {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{textLines}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const Icon = React.forwardRef(
|
||||
(
|
||||
{ className, ...props }: PropsWithChildren<BaseProps>,
|
||||
ref: Ref<OrNull<HTMLSpanElement>>
|
||||
) => (
|
||||
<span
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'material-icons',
|
||||
className,
|
||||
css`
|
||||
font-size: 18px;
|
||||
vertical-align: text-bottom;
|
||||
`
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
export const Instruction = React.forwardRef(
|
||||
(
|
||||
{ className, ...props }: PropsWithChildren<BaseProps>,
|
||||
ref: Ref<OrNull<HTMLDivElement>>
|
||||
) => (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
white-space: pre-wrap;
|
||||
margin: 0 -20px 10px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
background: #f8f8e8;
|
||||
`
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
export const Menu = React.forwardRef(
|
||||
(
|
||||
{ className, ...props }: PropsWithChildren<BaseProps>,
|
||||
ref: Ref<OrNull<HTMLDivElement>>
|
||||
) => (
|
||||
<div
|
||||
{...props}
|
||||
data-test-id="menu"
|
||||
ref={ref}
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
& > * {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > * + * {
|
||||
margin-left: 15px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
export const Portal = ({ children }) => {
|
||||
return typeof document === 'object'
|
||||
? ReactDOM.createPortal(children, document.body)
|
||||
: null
|
||||
}
|
||||
|
||||
export const Toolbar = React.forwardRef(
|
||||
(
|
||||
{ className, ...props }: PropsWithChildren<BaseProps>,
|
||||
ref: Ref<OrNull<HTMLDivElement>>
|
||||
) => (
|
||||
<Menu
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
position: relative;
|
||||
padding: 1px 18px 17px;
|
||||
margin: 0 -20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
margin-bottom: 20px;
|
||||
`
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)
|
161
src/components/SimpleSlateEditor.tsx
Normal file
161
src/components/SimpleSlateEditor.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import React, {useCallback, useMemo, useState} from "react";
|
||||
import {Slate, withReact, Editable} from "slate-react";
|
||||
import {
|
||||
createEditor, Descendant,
|
||||
Element as SlateElement
|
||||
} from "slate";
|
||||
import {Toolbar} from "./EditorComponents.tsx";
|
||||
import {css} from "@emotion/css";
|
||||
|
||||
const initialValue: any[] = [
|
||||
{
|
||||
type:'heading-one',
|
||||
children:[
|
||||
{text:'111'}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{text: 'This is editable '},
|
||||
{text: 'rich', bold: true},
|
||||
{text: ' text, '},
|
||||
{text: 'much', italic: true},
|
||||
{text: '测试一下', replace: true},
|
||||
{text: ' better than a '},
|
||||
{text: '<textarea>', code: true},
|
||||
{text: '!'},
|
||||
{text: '测试一下', replace: true},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
text:
|
||||
"Since it's rich text, you can do things like turn a selection of text ",
|
||||
},
|
||||
{text: 'bold', bold: true},
|
||||
{
|
||||
text:
|
||||
', or add a semantically rendered block quote in the middle of the page, like this:',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'block-quote',
|
||||
children: [{text: 'A wise quote.'}],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
align: 'center',
|
||||
children: [
|
||||
{text: '测试一下', replace: true},
|
||||
{text: 'Try it out for yourself!'}
|
||||
],
|
||||
},
|
||||
]
|
||||
const Element = ({attributes, children, element}: any) => {
|
||||
const style = {textAlign: element.align}
|
||||
switch (element.type) {
|
||||
case 'block-quote':
|
||||
return (
|
||||
<blockquote style={style} {...attributes}>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
case 'bulleted-list':
|
||||
return (
|
||||
<ul style={style} {...attributes}>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
case 'heading-one':
|
||||
return (
|
||||
<h1 style={style} {...attributes}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
case 'heading-two':
|
||||
return (
|
||||
<h2 style={style} {...attributes}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
case 'list-item':
|
||||
return (
|
||||
<li style={style} {...attributes}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
case 'numbered-list':
|
||||
return (
|
||||
<ol style={style} {...attributes}>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<p style={style} {...attributes}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
const Leaf = ({attributes, children, leaf}: any) => {
|
||||
if (leaf.bold) {
|
||||
children = <strong>{children}</strong>
|
||||
}
|
||||
|
||||
if (leaf.code) {
|
||||
children = <code>{children}</code>
|
||||
}
|
||||
if(leaf.replace){
|
||||
children = <span className={"text-replace-item"}>{children}</span>
|
||||
}
|
||||
|
||||
if (leaf.italic) {
|
||||
children = <em>{children}</em>
|
||||
}
|
||||
|
||||
if (leaf.underline) {
|
||||
children = <u>{children}</u>
|
||||
}
|
||||
|
||||
return <span {...attributes}>{children}</span>
|
||||
}
|
||||
export const SimpleSlateEditor: React.FC = () => {
|
||||
|
||||
const editor = useMemo(() => withReact(createEditor()), [])
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const renderElement = useCallback(props => <Element {...props} />, [])
|
||||
const renderLeaf = useCallback(props => <Leaf {...props} />, [])
|
||||
return (
|
||||
<div>
|
||||
<Slate
|
||||
editor={editor}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
>
|
||||
<Toolbar>
|
||||
<button>xxx1</button>
|
||||
<button>xxx2</button>
|
||||
<button>xxx3</button>
|
||||
</Toolbar>
|
||||
<Editable
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
placeholder="请输入要编辑的内容"
|
||||
autoFocus
|
||||
/>
|
||||
</Slate>
|
||||
<div className={css(`
|
||||
background:#eee;
|
||||
padding:10px;
|
||||
marginTop:20px;
|
||||
border:solid 1px #999;
|
||||
border-radius:4px;
|
||||
`)}>{JSON.stringify(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
42
src/components/SlateEditor/Editor.tsx
Normal file
42
src/components/SlateEditor/Editor.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React, {useEffect, useMemo, useState} from "react";
|
||||
import {Slate, withReact} from "slate-react";
|
||||
import {createEditor, Descendant} from "slate";
|
||||
import {slateNodesToInsertDelta, withYjs, YjsEditor} from "@slate-yjs/core";
|
||||
import * as Y from "yjs"
|
||||
|
||||
// const v: Descendant = {
|
||||
// children: [], text: ""
|
||||
//
|
||||
// }
|
||||
const initialValue:Descendant[] = [{
|
||||
text: 'paragraph',
|
||||
children: [{text: 'A line of text in a paragraph.'}],
|
||||
}]
|
||||
export const Editor: React.FC = () => {
|
||||
|
||||
const [value, setValue] = useState(initialValue)
|
||||
// Create a yjs document and get the shared type
|
||||
const sharedType = useMemo(() => {
|
||||
const yDoc = new Y.Doc()
|
||||
const sharedType = yDoc.get("content", Y.XmlText) as unknown as Y.XmlText
|
||||
|
||||
// Load the initial value into the yjs document
|
||||
sharedType.applyDelta(slateNodesToInsertDelta(initialValue))
|
||||
|
||||
return sharedType
|
||||
}, [])
|
||||
|
||||
const editor = useMemo(() => withYjs(withReact(createEditor()),sharedType), [])
|
||||
useEffect(()=>{
|
||||
YjsEditor.connected(editor);
|
||||
return ()=> YjsEditor.disconnect(editor)
|
||||
},[editor])
|
||||
|
||||
return (
|
||||
<Slate
|
||||
editor={editor}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)
|
||||
}
|
51
src/components/SlateEditor/FormatToolbar/FormatButton.tsx
Normal file
51
src/components/SlateEditor/FormatToolbar/FormatButton.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { Editor, Text, Transforms } from 'slate';
|
||||
import { useSlate } from 'slate-react';
|
||||
import * as classNames from "classnames";
|
||||
|
||||
function isFormatActive(editor: Editor, format: string) {
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => Text.isText(n) && !n[format],
|
||||
mode: 'all',
|
||||
});
|
||||
return !match;
|
||||
}
|
||||
|
||||
function toggleFormat(editor: Editor, format: string) {
|
||||
const isActive = isFormatActive(editor, format);
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{ [format]: isActive ? null : true },
|
||||
{ match: Text.isText, split: true }
|
||||
);
|
||||
}
|
||||
|
||||
type FormatButtonProps = {
|
||||
format: string;
|
||||
icon: IconDefinition;
|
||||
};
|
||||
|
||||
export const FormatButton:React.FC<FormatButtonProps> = ({ format, icon }) => {
|
||||
const editor = useSlate();
|
||||
const active = isFormatActive(editor, format);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'h-8 w-8 flex justify-center items-center hover:bg-gray-600'
|
||||
)}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
toggleFormat(editor, format);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
className={active ? 'text-primary' : 'text-white'}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
71
src/components/SlateEditor/FormatToolbar/FormatToolbar.tsx
Normal file
71
src/components/SlateEditor/FormatToolbar/FormatToolbar.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { ReactNode, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Editor, Range } from 'slate';
|
||||
import { useFocused, useSlate } from 'slate-react';
|
||||
import {
|
||||
faBold,
|
||||
faItalic,
|
||||
faStrikethrough,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FormatButton } from './FormatButton';
|
||||
|
||||
type PortalProps = { children?: ReactNode };
|
||||
|
||||
function Portal({ children }: PortalProps) {
|
||||
return typeof document === 'object'
|
||||
? ReactDOM.createPortal(children, document.body)
|
||||
: null;
|
||||
}
|
||||
|
||||
export const FormatToolbar:React.FC = ()=> {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const editor = useSlate();
|
||||
const focused = useFocused();
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
const { selection } = editor;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!selection ||
|
||||
!focused ||
|
||||
Range.isCollapsed(selection) ||
|
||||
Editor.string(editor, selection) === ''
|
||||
) {
|
||||
el.removeAttribute('style');
|
||||
return;
|
||||
}
|
||||
|
||||
const domSelection = window.getSelection();
|
||||
if (!domSelection?.rangeCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domRange = domSelection.getRangeAt(0);
|
||||
|
||||
const rect = domRange.getBoundingClientRect();
|
||||
el.style.opacity = '1';
|
||||
el.style.top = `${rect.top + window.pageYOffset - el.offsetHeight - 6}px`;
|
||||
el.style.left = `${
|
||||
rect.left + window.pageXOffset - el.offsetWidth / 2 + rect.width / 2
|
||||
}px`;
|
||||
});
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute opacity-0 flex flex-row rounded bg-black overflow-hidden"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<FormatButton format="bold" icon={faBold} />
|
||||
<FormatButton format="italic" icon={faItalic} />
|
||||
<FormatButton format="strikethrough" icon={faStrikethrough} />
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
48
src/components/SlateEditor/SlateSimple.tsx
Normal file
48
src/components/SlateEditor/SlateSimple.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as Y from 'yjs'
|
||||
import React, {useMemo, useState} from "react";
|
||||
import {Slate, withReact} from "slate-react";
|
||||
import {createEditor, Descendant} from "slate";
|
||||
import {withYHistory, withYjs} from "@slate-yjs/core";
|
||||
import {FormatToolbar} from "./FormatToolbar/FormatToolbar.tsx";
|
||||
|
||||
|
||||
function withNormalize(editor: Editor) {
|
||||
const { normalizeNode } = editor;
|
||||
|
||||
// Ensure editor always has at least one child.
|
||||
editor.normalizeNode = (entry) => {
|
||||
const [node] = entry;
|
||||
if (!Editor.isEditor(node) || node.children.length > 0) {
|
||||
return normalizeNode(entry);
|
||||
}
|
||||
|
||||
Transforms.insertNodes(
|
||||
editor,
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [{ text: '' }],
|
||||
},
|
||||
{ at: [0] }
|
||||
);
|
||||
};
|
||||
|
||||
return editor;
|
||||
}
|
||||
export const SlateSimple: React.FC = ()=>{
|
||||
const [value, setValue] = useState<Descendant[]>([]);
|
||||
const editor = useMemo(() => {
|
||||
const sharedType = provider.document.get('content', Y.XmlText) as Y.XmlText;
|
||||
|
||||
return withNormalize(withReact(
|
||||
withYHistory(
|
||||
withYjs(createEditor(), sharedType, { autoConnect: false })
|
||||
)
|
||||
));
|
||||
}, [provider.document]);
|
||||
return (<div>
|
||||
<Slate editor={editor} value={value}>
|
||||
<FormatToolbar />
|
||||
<CustomEditable className="max-w-4xl w-full flex-col break-words" />
|
||||
</Slate>
|
||||
</div>)
|
||||
}
|
15
src/components/icons/IconArrowRight.tsx
Normal file
15
src/components/icons/IconArrowRight.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import {cx} from "@emotion/css";
|
||||
import {SVGProps} from "react"
|
||||
|
||||
const IconArrowRight = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
className={cx('svg-icon icon-arrow icon-arrow-right',props.className)}
|
||||
viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M64 552.013h836v64h-836z"></path>
|
||||
<path d="M768 408.987l192 207.026h-192z"></path>
|
||||
</svg>)
|
||||
export default IconArrowRight;
|
17
src/components/icons/IconCancelFill.tsx
Normal file
17
src/components/icons/IconCancelFill.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import {SVGProps} from "react"
|
||||
import {cx} from "@emotion/css";
|
||||
|
||||
|
||||
const IconCancelFill = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
className={cx('svg-icon icon-cancel-fill', props.className)}
|
||||
viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M512 64C264.992 64 64 264.96 64 512s200.96 448 448 448c247.008 0 448-200.96 448-448S759.04 64 512 64zM694.752 649.984c12.48 12.544 12.448 32.768-0.064 45.248-6.24 6.208-14.4 9.344-22.592 9.344-8.224 0-16.416-3.136-22.656-9.408l-137.6-138.016-138.048 136.576c-6.24 6.144-14.368 9.248-22.496 9.248-8.256 0-16.48-3.168-22.752-9.504-12.416-12.576-12.32-32.8 0.256-45.248l137.888-136.384-137.376-137.824c-12.48-12.512-12.448-32.768 0.064-45.248 12.512-12.512 32.736-12.448 45.248 0.064l137.568 137.984 138.048-136.576c12.544-12.448 32.832-12.32 45.248 0.256 12.448 12.576 12.32 32.832-0.256 45.248l-137.888 136.384L694.752 649.984z"
|
||||
></path>
|
||||
</svg>)
|
||||
export default IconCancelFill;
|
17
src/components/icons/IconCheckFill.tsx
Normal file
17
src/components/icons/IconCheckFill.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import {cx} from "@emotion/css";
|
||||
import {SVGProps} from "react"
|
||||
|
||||
|
||||
const IconCheckFill = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
className={cx('svg-icon icon-check-fill', props.className)}
|
||||
viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m193.5 301.7l-210.6 292c-12.7 17.7-39 17.7-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"
|
||||
/>
|
||||
</svg>)
|
||||
export default IconCheckFill;
|
98
src/components/quill/QuillEditor.tsx
Normal file
98
src/components/quill/QuillEditor.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import React, {useEffect, useRef} from "react";
|
||||
import './ReplaceParser.tsx'
|
||||
import ReactQuill, {Quill, Range, UnprivilegedEditor} from "react-quill";
|
||||
import {DeltaStatic, Sources} from 'quill'
|
||||
import 'react-quill/dist/quill.snow.css';
|
||||
import {EDITOR} from "./editor.ts";
|
||||
|
||||
type QuillEditorProps = {
|
||||
selected?: HTMLElement;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
export const QuillEditor: React.FC<QuillEditorProps> = (props) => {
|
||||
const editorInstance = useRef<ReactQuill>(null);
|
||||
const demoDiv = useRef<HTMLDivElement>(null)
|
||||
const handleChange = (_value: string, _delta: DeltaStatic, _source: Sources, _editor: UnprivilegedEditor) => {
|
||||
console.log('handleChange==>', _source)
|
||||
// setContent(
|
||||
// editor.getContents()
|
||||
// )
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (editorInstance.current && editorInstance.current.editor) {
|
||||
EDITOR.instance = editorInstance.current.editor;
|
||||
EDITOR.on('demo',data=>{
|
||||
console.log('demo',data)
|
||||
})
|
||||
}
|
||||
}, [editorInstance])
|
||||
useEffect(() => {
|
||||
if (demoDiv.current) {
|
||||
demoDiv.current.addEventListener('text-change', (e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}
|
||||
}, [demoDiv])
|
||||
const handleSelectChange = (selection: Range, _source: Sources, _editor: UnprivilegedEditor) => {
|
||||
if (_source == 'api') {
|
||||
|
||||
console.log('handleSelectChange==>', _source, selection)
|
||||
}
|
||||
}
|
||||
const handleReplaceItemClick = (node: HTMLElement) => {
|
||||
const blot = Quill.find(node)
|
||||
const offset = blot.offset(blot.scroll), length = blot.length()
|
||||
editorInstance.current?.editor?.setSelection(offset, length)
|
||||
}
|
||||
const handleEditorClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
const span = e.target as HTMLSpanElement;
|
||||
if (!span) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return;
|
||||
}
|
||||
if (span.classList.contains('text-replace-item')) {
|
||||
handleReplaceItemClick(span)
|
||||
}
|
||||
if (props.onClick) {
|
||||
props.onClick(e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props.selected) {
|
||||
handleReplaceItemClick(props.selected)
|
||||
}
|
||||
}, [props])
|
||||
// modules={{toolbar: false}}
|
||||
const modules = {
|
||||
toolbar: [
|
||||
[{'size': []}],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{'color': []}, {'background': []}],
|
||||
[{'script': 'super'}, {'script': 'sub'}],
|
||||
[{'header': '1'}, {'header': '2'}, 'blockquote', 'code-block'],
|
||||
[{'list': 'ordered'}, {'list': 'bullet'}, {'indent': '-1'}, {'indent': '+1'}],
|
||||
[{'direction': 'rtl'}, {'align': []}],
|
||||
['link', 'image', 'video', 'formula'],
|
||||
['clean']
|
||||
],
|
||||
}
|
||||
return (<div className="editor-container" onClick={handleEditorClick} ref={demoDiv}>
|
||||
<div className="editor-toolbar-container">
|
||||
<button onClick={() => {
|
||||
console.log(EDITOR)
|
||||
}}>test
|
||||
</button>
|
||||
</div>
|
||||
<ReactQuill
|
||||
ref={editorInstance}
|
||||
theme="snow"
|
||||
modules={modules}
|
||||
onChange={handleChange}
|
||||
onChangeSelection={handleSelectChange}
|
||||
/>
|
||||
</div>)
|
||||
|
||||
}
|
80
src/components/quill/ReplaceParser.tsx
Normal file
80
src/components/quill/ReplaceParser.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import {Quill} from "react-quill";
|
||||
import {ReplaceItemAttribute} from "../../types/editor.ts";
|
||||
import {EDITOR} from "./editor.ts";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const Inline = Quill.import('blots/inline');
|
||||
|
||||
/**
|
||||
* 替换对象
|
||||
*/
|
||||
class ReplaceParserBlot extends Inline {
|
||||
static tagName = "span";
|
||||
static className = "text-replace-item";
|
||||
static blotName = "replace";
|
||||
|
||||
static create(value: ReplaceItemAttribute) {
|
||||
console.log('ReplaceParserBlot==>create', value)
|
||||
// 创建元素对象
|
||||
const node = super.create('span') as HTMLSpanElement;
|
||||
if (value && value.id) {
|
||||
node.setAttribute("data-replace", 'id_' + value.id);
|
||||
node.setAttribute("data-replace-text", (value.text || value.origin));
|
||||
node.setAttribute("data-replace-type", value.type);
|
||||
node.classList.add(`replace-type-${value.type}`)
|
||||
if (value.processed) {
|
||||
node.classList.add(`processed`, `processed-${value.processed}`)
|
||||
}
|
||||
node.onclick = () => {
|
||||
EDITOR.trigger('demo', value)
|
||||
|
||||
// console.log(node, value,this)
|
||||
// const e = new Event('replace-item-select')
|
||||
// node.dispatchEvent(e)
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
onClick() {
|
||||
console.log('~~~~~~~', this)
|
||||
}
|
||||
|
||||
static value(node: HTMLElement) {
|
||||
console.log('333', node)
|
||||
return {
|
||||
// alt: node.getAttribute('alt'),
|
||||
id: node.getAttribute('data-replace')?.replace('id_', ''),
|
||||
text: node.getAttribute('data-replace-text'),
|
||||
type: node.getAttribute('data-replace-type')
|
||||
};
|
||||
}
|
||||
|
||||
// 从对象解析数据
|
||||
static formats(node: HTMLElement) {
|
||||
// console.log('22', node)
|
||||
return {
|
||||
id: node.getAttribute('data-replace')?.replace('id_', ''),
|
||||
text: node.getAttribute('data-replace-text'),
|
||||
type: node.getAttribute('data-replace-type')
|
||||
};
|
||||
}
|
||||
|
||||
// format(name, value) {
|
||||
// console.log('name,value==>', name, value)
|
||||
// if (name === 'link' && value) {
|
||||
// this.domNode.setAttribute('href', value);
|
||||
// } else {
|
||||
// super.format(name, value);
|
||||
// }
|
||||
// }
|
||||
|
||||
// formats() {
|
||||
// const formats = super.formats();
|
||||
// console.log('formats', this.domNode, formats)
|
||||
// formats['replace'] = ReplaceParserBlot.formats(this.domNode, formats);
|
||||
// return formats;
|
||||
// }
|
||||
}
|
||||
|
||||
Quill.register(ReplaceParserBlot, true);
|
44
src/components/quill/editor.ts
Normal file
44
src/components/quill/editor.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {RangeStatic, Quill, DeltaStatic} from "quill";
|
||||
import {EditorContent, EditorFormat} from "../../types/editor.ts";
|
||||
|
||||
export const EDITOR: {
|
||||
instance: Quill | null,
|
||||
setContents: (content: EditorContent) => void;
|
||||
trigger: (eventType: string, data: any) => void;
|
||||
on(type: string, handler: (data: any) => void): void;
|
||||
} = {
|
||||
instance: null,
|
||||
setContents: (content) => {
|
||||
EDITOR.instance?.setContents(content as DeltaStatic)
|
||||
},
|
||||
trigger: (eventType, data) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
EDITOR.instance?.emitter.emit(eventType, data);
|
||||
},
|
||||
on: (type, handler) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
EDITOR.instance?.on(type, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// const FormatLines = ['align']
|
||||
export function initEditorProcessor(editor: Quill) {
|
||||
return {
|
||||
// 格式化
|
||||
format(range: RangeStatic, format: keyof EditorFormat, value: string | number | boolean) {
|
||||
const {index, length} = range
|
||||
if (format == 'align') {
|
||||
editor.formatLine(index, length, format, value);
|
||||
return;
|
||||
}
|
||||
editor.formatText(range, format, value);
|
||||
},
|
||||
selectNode(node: HTMLElement) {
|
||||
const blot = Quill.find(node)
|
||||
const offset = blot.offset(blot.scroll), length = blot.length()
|
||||
editor.setSelection(offset, length)
|
||||
}
|
||||
}
|
||||
}
|
69
src/index.css
Normal file
69
src/index.css
Normal file
@ -0,0 +1,69 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
9
src/main.tsx
Normal file
9
src/main.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
// import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
// <React.StrictMode>
|
||||
<App />
|
||||
// </React.StrictMode>,
|
||||
)
|
32
src/pages/quill/text-editor.less
Normal file
32
src/pages/quill/text-editor.less
Normal file
@ -0,0 +1,32 @@
|
||||
.editor-container {
|
||||
--editor-base-font-size: 16px;
|
||||
|
||||
.editor-toolbar-container {
|
||||
}
|
||||
|
||||
.ql-editor {
|
||||
font-size: var(--editor-base-font-size, 14px);
|
||||
line-height: 1.6;
|
||||
|
||||
p + p {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑样式
|
||||
.text-replace-item {
|
||||
cursor: pointer;
|
||||
border-bottom: solid 3px #f00;
|
||||
//position: relative;
|
||||
|
||||
//&:after {
|
||||
// content: ' ';
|
||||
// position: absolute;
|
||||
// left: 0;
|
||||
// right: 0;
|
||||
// bottom: -5px;
|
||||
// border-bottom: solid 3px #f00;
|
||||
//}
|
||||
}
|
||||
|
||||
}
|
195
src/pages/quill/text.tsx
Normal file
195
src/pages/quill/text.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import React, {useRef, useState} from "react";
|
||||
import {useMount, useSetState} from "ahooks";
|
||||
import {cx} from "@emotion/css";
|
||||
import {QuillEditor} from "../../components/quill/QuillEditor.tsx";
|
||||
import IconArrowRight from "../../components/icons/IconArrowRight.tsx";
|
||||
import './text-editor.less'
|
||||
import {EditorContent, ReplaceItemAttribute} from "../../types/editor.ts";
|
||||
import {EDITOR} from "../../components/quill/editor.ts";
|
||||
|
||||
const contentObject: EditorContent = {
|
||||
"ops": [
|
||||
{
|
||||
"attributes": {bold: true},
|
||||
"insert": "作者:小飞飞,撰写于6月31日。"
|
||||
},
|
||||
{insert: '\n', attributes: {align: 'center'}},
|
||||
// {"insert": "\n想当年,他所带领的军队以锐不可挡之势,"},
|
||||
// {
|
||||
// "attributes": {"bold": true},
|
||||
// "insert": "横扫大江南北"
|
||||
// },
|
||||
{"insert": ",可以说是在父兄基业上"},
|
||||
{
|
||||
"insert": "既往开来",
|
||||
"attributes": {
|
||||
"replace": {
|
||||
origin: '既往开来',
|
||||
text: '继往开来',
|
||||
type: 'words'
|
||||
},
|
||||
"bold": true,
|
||||
},
|
||||
},
|
||||
// {"insert": "既往开来,成就了一番伟业。原本偏安一隅的小国,从他的手中变成了十三个州,国人对这位领袖的敬意由然而生。"},
|
||||
// {
|
||||
// "attributes": {
|
||||
// "replace": {
|
||||
// origin: '威望的增加',
|
||||
// type: 'delete'
|
||||
// }
|
||||
// },
|
||||
// "insert": "威望的增加",
|
||||
// },
|
||||
{
|
||||
"insert": "、权利的扩张丝毫没有改变他原有的",
|
||||
},
|
||||
// {
|
||||
// "attributes": {
|
||||
// "replace": {
|
||||
// origin: '样',
|
||||
// type: 'delete'
|
||||
// }
|
||||
// },
|
||||
// "insert": "样",
|
||||
// },
|
||||
// {
|
||||
// "insert": "样子,他迈步走进岳楼,回忆起在湖北省张家界市的一段往事。" +
|
||||
// "那是一个薄雾蒙蒙的清晨,在急促行军途中他与一位素未谋面的人相逢,虽然之后并没有太多故事,却至今难以忘却,正当他的思绪陷入过往," +
|
||||
// "忽然一阵震天的马蹄声夹杂着士兵的喧闹传来,报:“敌人来袭,我方战线危机,望将军火速驰援”。" +
|
||||
// "由于刚刚陷入过往的原因,他稍微愣了愣神,咆哮道:“大军听令,即刻出发”!军令如山。成群的士兵迅速从营房中跑出在校场上整齐队列," +
|
||||
// "方阵如虹、战马昂首、刀枪如林、战旗迎风飘扬,将士身上的盔甲在阳光照射下,闪耀着金属的光泽。" +
|
||||
// "看着这支曾跟着他南征北战的队伍,他默默翻身登上战马,走在队伍最前面。营房外的道路两旁站满了欢送的百姓,大家希望将军能带领着军队,再次创造奇迹。",
|
||||
// },
|
||||
{
|
||||
"attributes": {
|
||||
replace: {
|
||||
origin: '湖北省张家界市',
|
||||
text: '湖南省张家界市',
|
||||
type: 'address',
|
||||
},
|
||||
// "bold":true,
|
||||
},
|
||||
"insert": "湖北省张家界市",
|
||||
},
|
||||
{
|
||||
"insert": "\n来源:",
|
||||
},
|
||||
{"retain": 6},
|
||||
{"delete": 5},
|
||||
{
|
||||
"attributes": {link: "https://zj.xfyun.cn/exam/text"},
|
||||
"insert": "讯飞"
|
||||
},
|
||||
// {"insert": "\n"}
|
||||
]
|
||||
};
|
||||
// 地名纠错 成语纠错
|
||||
type ReplaceItemProp = {
|
||||
id?: number;
|
||||
item: ReplaceItemAttribute;
|
||||
onCheck?: () => void;
|
||||
onCancel?: () => void;
|
||||
onClick?: () => void;
|
||||
state?: 'normal' | 'selected' | 'confirm' | 'cancel';
|
||||
}
|
||||
|
||||
// type ReplaceState = 'normal' | 'selected' | 'confirm' | 'cancel';
|
||||
|
||||
const zeroFill = (v: any) => String(v).padStart(2, '0')
|
||||
const ReplaceItem: React.FC<ReplaceItemProp> = (prop) => {
|
||||
return (
|
||||
<div className={cx('replace-item', `replace-state-${prop.state || 'normal'}`, `replace-type-${prop.item.type}`)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
prop.onClick && prop.onClick();
|
||||
}}>
|
||||
<div className="data">
|
||||
<span className="id">{zeroFill(prop.id)}</span>
|
||||
<span className="origin">{prop.item.origin}</span>
|
||||
|
||||
{prop.item.type != 'delete' ? <>
|
||||
<span className="arrow"><IconArrowRight/></span>
|
||||
<span className="text">{prop.item.text}</span>
|
||||
</> : <span className="delete-text">(删除)</span>}
|
||||
</div>
|
||||
<div className="action">
|
||||
<span>选用</span>
|
||||
<span>取消</span>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
const TextPage: React.FC = () => {
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
// const [selectNode, setSelectNode] = useState<HTMLElement>()
|
||||
// const handleEditorClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
// const span = e.target as HTMLSpanElement;
|
||||
// if (span.classList.contains('text-replace-item')) {
|
||||
// // message.info("测试" + span.innerText)
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
const handleReplaceItemClick = (p: ReplaceItemAttribute) => {
|
||||
const node = editorRef.current?.querySelector(`span[data-replace=id_${p.id}]`);
|
||||
if (node) {
|
||||
// console.log(node)
|
||||
// setSelectNode(node)
|
||||
}
|
||||
};
|
||||
const [opsList, setOpsList] = useState<ReplaceItemAttribute[]>([])
|
||||
const [data, setData] = useSetState<{
|
||||
node?: number;
|
||||
index?: number;
|
||||
}>({})
|
||||
const mockProcess = () => {
|
||||
const _arr: ReplaceItemAttribute[] = [];
|
||||
let index = 0;
|
||||
contentObject.ops.forEach(p => {
|
||||
if (p.attributes && p.attributes.replace) {
|
||||
p.attributes.replace.id = (++index);
|
||||
_arr.push(p.attributes.replace)
|
||||
}
|
||||
})
|
||||
setOpsList(_arr)
|
||||
}
|
||||
|
||||
useMount(() => {
|
||||
if (editorRef.current) {
|
||||
console.log(editorRef.current)
|
||||
// editorRef.current.addEventListener('editor-change',(e:{type:string,data:string})=>{
|
||||
//
|
||||
// },false)
|
||||
}
|
||||
mockProcess();
|
||||
EDITOR.setContents(contentObject)
|
||||
});
|
||||
|
||||
|
||||
return (<div className={'page-text'}>
|
||||
<div className="page-block-article">
|
||||
<div className="page-block-content" ref={editorRef}>
|
||||
<QuillEditor/>
|
||||
</div>
|
||||
<div className="page-block-control">
|
||||
<div className="replace-content-container">
|
||||
{opsList.map((p, index) => (
|
||||
<ReplaceItem
|
||||
key={index}
|
||||
id={index + 1}
|
||||
item={p}
|
||||
state={data.index == p.id ? 'selected' : 'normal'}
|
||||
onClick={() => {
|
||||
setData({index: p.id})
|
||||
handleReplaceItemClick(p)
|
||||
}}
|
||||
/>))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
export default TextPage
|
11
src/pages/wang/components/ProofreadPopover.tsx
Normal file
11
src/pages/wang/components/ProofreadPopover.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import {Popover} from "antd";
|
||||
import {TooltipPlacement} from "antd/es/tooltip";
|
||||
|
||||
export const ProofreadPopover: React.FC<{
|
||||
content: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
open?: boolean;
|
||||
placement?: TooltipPlacement;
|
||||
}> = (props) => (<Popover placement={props.placement} overlayClassName="proofread-popover" content={props.content}
|
||||
open={props.open}>{props.children}</Popover>)
|
16
src/pages/wang/plugins/index.ts
Normal file
16
src/pages/wang/plugins/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import {Boot, IModuleConf} from '@wangeditor/editor'
|
||||
import {renderProofread, withProofread} from "./proofread.ts";
|
||||
|
||||
const renderElemConf = {
|
||||
type: 'proofread', // 新元素 type ,重要!!!
|
||||
renderElem: renderProofread,
|
||||
}
|
||||
|
||||
const module: Partial<IModuleConf> = { // TS 语法
|
||||
editorPlugin: withProofread, // 插件
|
||||
renderElems: [renderElemConf, /* 其他元素... */] // renderElem
|
||||
|
||||
// 其他功能,下文讲解...
|
||||
}
|
||||
|
||||
Boot.registerModule(module)
|
59
src/pages/wang/plugins/proofread.ts
Normal file
59
src/pages/wang/plugins/proofread.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {DomEditor, IDomEditor, SlateElement} from "@wangeditor/editor";
|
||||
import {h, VNode} from "snabbdom";
|
||||
import {WangEditorProofreadElement} from "../../../types/editor.ts";
|
||||
|
||||
const ELEMENT_TYPE = 'proofread';
|
||||
|
||||
export function withProofread<T extends IDomEditor>(editor: T) {
|
||||
const {isInline, isVoid} = editor;
|
||||
const newEditor = editor;
|
||||
newEditor.isInline = ele => {
|
||||
const type = DomEditor.getNodeType(ele) // 获取元素类型
|
||||
return type == ELEMENT_TYPE ? true : isInline(ele);// 只针对校对类型返回为内联
|
||||
}
|
||||
newEditor.isVoid = ele => {
|
||||
const type = DomEditor.getNodeType(ele)
|
||||
return type == ELEMENT_TYPE ? false : isVoid(ele);// 只针对校对类型返回为内联
|
||||
}
|
||||
return newEditor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染“校对”元素到编辑器
|
||||
* @param data 附件元素,即上文的 myResume
|
||||
* @param children 元素子节点,void 元素可忽略 @param _editor 编辑器实例
|
||||
* @returns 节点(通过 snabbdom.js 的 h 函数生成)
|
||||
*/
|
||||
export function renderProofread(data: SlateElement, children: VNode[] | null) { // , _editor: IDomEditor
|
||||
const {proofread} = data as WangEditorProofreadElement;
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
props: {
|
||||
dataSlateId: '123123'
|
||||
}, // HTML 属性,驼峰式写法
|
||||
attrs: {
|
||||
'data-proofread-id': String(proofread.id)
|
||||
},
|
||||
attachData: {
|
||||
proofread
|
||||
},
|
||||
className: `data-proofread-item data-proofread-item-${proofread.id}`,
|
||||
style: { /* 其他... */}, // style ,驼峰式写法 display: 'inline-block', margin: '0 2px',
|
||||
on: {
|
||||
click(e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
console.log(this)
|
||||
|
||||
const span = this.elm as HTMLSpanElement;
|
||||
const ev = new Event('proofread-click', {
|
||||
bubbles: true
|
||||
})
|
||||
span.dispatchEvent(ev);
|
||||
}, /* 其他... */
|
||||
}
|
||||
},
|
||||
children
|
||||
);
|
||||
}
|
133
src/pages/wang/style.less
Normal file
133
src/pages/wang/style.less
Normal file
@ -0,0 +1,133 @@
|
||||
.wang-editor-page {
|
||||
--bottom-height: 100px;
|
||||
--operation-width: 400px;
|
||||
--operation-box-shadow: 0 0 5px rgba(116, 123, 255, 0.1);
|
||||
--operation-background: rgba(116, 123, 255, 0.05);
|
||||
--operation-background-hover: rgba(116, 123, 255, 0.15);
|
||||
|
||||
--operation-background-select: rgba(255, 255, 255, 1);
|
||||
--operation-box-shadow-select: 0 0 8px rgba(116, 123, 255, 0.3);
|
||||
|
||||
--operation-border: solid 1px rgba(116, 123, 255, 0.1);
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.data-proofread-item {
|
||||
border-bottom: solid 3px #f00;
|
||||
display: inline;
|
||||
margin: 0 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-proofread-item-selected {
|
||||
background-color: #fbbbbb;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--bottom-height));
|
||||
}
|
||||
|
||||
.wang-editor-instance {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.operation-wrapper {
|
||||
width: var(--operation-width);
|
||||
padding: 10px 0;
|
||||
border-left: solid 1px rgba(116, 123, 255, 0.15);
|
||||
font-size: 14px;
|
||||
|
||||
.operation-container {
|
||||
//background-color: rgba(116, 123, 255, 0.15);
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.operation-proofread-item {
|
||||
background-color: var(--operation-background);
|
||||
border: var(--operation-border);
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--operation-box-shadow);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--operation-background-hover);
|
||||
}
|
||||
|
||||
.proofread-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.origin {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin: 0 6px;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
color: #333;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.proofread-description {
|
||||
display: none;
|
||||
color: #666;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.proofread-action {
|
||||
display: none;
|
||||
align-items: center;
|
||||
|
||||
.svg-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: 10px;
|
||||
color: #aaa;
|
||||
|
||||
&:hover {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--operation-background-select);
|
||||
box-shadow: var(--operation-box-shadow-select);
|
||||
|
||||
.proofread-description, .proofread-action {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部内容
|
||||
.content {
|
||||
height: var(--bottom-height);
|
||||
overflow: auto;
|
||||
border-top: solid 1px rgba(116, 123, 255, 0.15);
|
||||
}
|
||||
}
|
226
src/pages/wang/wang-editor.tsx
Normal file
226
src/pages/wang/wang-editor.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import React, {MouseEvent, useRef, useState} from "react";
|
||||
import './plugins/index.ts'
|
||||
import {Editor} from '@wangeditor/editor-for-react'
|
||||
import {IDomEditor} from "@wangeditor/editor";
|
||||
import {Descendant} from "slate";
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import './style.less'
|
||||
import {PROOFREAD_TYPE_DESC, WangEditorContent, WangEditorProofreadElement} from "../../types/editor.ts";
|
||||
import {useMount} from "ahooks";
|
||||
import {Button} from "antd";
|
||||
import * as classNames from "classnames";
|
||||
import IconArrowRight from "../../components/icons/IconArrowRight.tsx";
|
||||
import IconCheckFill from "../../components/icons/IconCheckFill.tsx";
|
||||
import IconCancelFill from "../../components/icons/IconCancelFill.tsx";
|
||||
import {ProofreadPopover} from "./components/ProofreadPopover.tsx";
|
||||
|
||||
// {
|
||||
// "attributes": {
|
||||
// replace: {
|
||||
// origin: '湖北省张家界市',
|
||||
// text: '湖南省张家界市',
|
||||
// type: 'address',
|
||||
// },
|
||||
// // "bold":true,
|
||||
// },
|
||||
// "insert": "湖北省张家界市",
|
||||
// }
|
||||
const defaultData: WangEditorContent[] = [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"children": [
|
||||
{"text": "《", "fontFamily": "宋体"},
|
||||
{
|
||||
"text": "皇帝的新装",
|
||||
"bold": true,
|
||||
"color": "rgb(106, 57, 201)"
|
||||
|
||||
},
|
||||
{"text": "》的作者是", "fontFamily": "宋体"},
|
||||
{
|
||||
"text": "闻名世界",
|
||||
"bold": true,
|
||||
"italic": true,
|
||||
"underline": true,
|
||||
"through": true,
|
||||
"fontSize": "18pt"
|
||||
},
|
||||
{"text": "的", "fontFamily": "宋体"},
|
||||
{
|
||||
type: 'proofread',
|
||||
proofread: {
|
||||
origin: '湖北省张家界市',
|
||||
text: '湖南省张家界市',
|
||||
type: 'address',
|
||||
},
|
||||
children: [
|
||||
{"text": "湖北省张家界市"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "丹麦作家",
|
||||
"bold": true,
|
||||
"color": "rgb(54, 88, 226)"
|
||||
},
|
||||
{
|
||||
type: 'proofread',
|
||||
proofread: {
|
||||
origin: '安徒声',
|
||||
text: '安徒生',
|
||||
type: 'words',
|
||||
},
|
||||
children: [
|
||||
{"text": "安徒声"},
|
||||
]
|
||||
},
|
||||
{"text": "的作品。", "fontFamily": "宋体"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"children": [
|
||||
{
|
||||
"text": "故事里有一个愚蠢的笨国王。他罕少关心国家,一昧追求的就是衣着入时。",
|
||||
},
|
||||
{
|
||||
"text": "有一天,王国里来了俩个骗子。",
|
||||
"fontFamily": "宋体"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
const WangEditor: React.FC = () => {
|
||||
const [html, setHtml] = useState<string>()
|
||||
const [content, setContent] = useState<Descendant[]>()
|
||||
const [editor, setEditor] = useState<IDomEditor>() // 存储 editor 实例
|
||||
const [ops, setOps] = useState<WangEditorProofreadElement[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(-1);
|
||||
const editorConfig = {
|
||||
placeholder: '请输入内容...',
|
||||
}
|
||||
const onMounted = (editor: IDomEditor) => {
|
||||
setEditor(editor)
|
||||
editor.children = defaultData
|
||||
editor.on('select', (a, b) => {
|
||||
console.log('select=》a,b', a, b)
|
||||
})
|
||||
}
|
||||
const parseDesc = (data: WangEditorProofreadElement) => {
|
||||
const {type, origin, text} = data.proofread
|
||||
const desc = PROOFREAD_TYPE_DESC[type];
|
||||
return desc.replace(/%origin%/ig, origin).replace(/%text%/ig, text)
|
||||
}
|
||||
const divRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
||||
const handleDivClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (divRef.current) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('data-proofread-item')) {
|
||||
target.classList.add('data-proofread-item-selected')
|
||||
return;
|
||||
}
|
||||
const selectArr = divRef.current.querySelectorAll('.data-proofread-item-selected')
|
||||
Array.from(selectArr).forEach(it => it.classList.remove('data-proofread-item-selected'))
|
||||
setCurrentIndex(-1)
|
||||
}
|
||||
}
|
||||
useMount(() => {
|
||||
const _arr: WangEditorProofreadElement[] = [];
|
||||
let index = 0;
|
||||
defaultData.forEach((it) => {
|
||||
if (it.type == 'paragraph' && it.children && it.children.length > 0) {
|
||||
it.children.forEach(cit => {
|
||||
if (Object.hasOwn(cit, 'type')) {
|
||||
const data = cit as WangEditorProofreadElement;
|
||||
if (data.type && data.type == 'proofread') {
|
||||
data.proofread.id = index++;
|
||||
data.proofread.description = parseDesc(data)
|
||||
_arr.push(data)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
setOps(_arr)
|
||||
if (divRef.current) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
divRef.current.addEventListener('proofread-click', handleDivClick)
|
||||
}
|
||||
})
|
||||
|
||||
const setSelectIndex = (index: number) => {
|
||||
setCurrentIndex(index)
|
||||
const span = document.querySelector(`.data-proofread-item-${index}`)
|
||||
if (span) {
|
||||
if (span.classList.contains('data-proofread-item-selected')) return;
|
||||
document.querySelector(`.data-proofread-item-selected`)?.classList.remove('data-proofread-item-selected')
|
||||
span.classList.add('data-proofread-item-selected')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (<div className="wang-editor-page">
|
||||
<div className={"editor-container"}>
|
||||
<div
|
||||
ref={divRef}
|
||||
onClick={handleDivClick}
|
||||
className="h-full flex-1"
|
||||
>
|
||||
<Editor
|
||||
className="wang-editor-instance h-full"
|
||||
defaultConfig={editorConfig}
|
||||
value={html}
|
||||
onCreated={onMounted}
|
||||
onChange={editor => {
|
||||
const op = editor.operations[0]
|
||||
if (op?.type == 'set_selection') {
|
||||
// console.log('onchange->', JSON.stringify(editor.selection))
|
||||
} else {
|
||||
setHtml(editor.getHtml())
|
||||
setContent(editor.children)
|
||||
// editor.getFragment
|
||||
}
|
||||
}}
|
||||
mode="default"
|
||||
/>
|
||||
</div>
|
||||
<div className="operation-wrapper">
|
||||
<div className="operation-container h-full">
|
||||
<div style={{margin: 10}}>
|
||||
<Button onClick={() => {
|
||||
const elems = editor?.getElemsByTypePrefix('header')
|
||||
console.log(elems)
|
||||
}}>获取所有目录</Button>
|
||||
</div>
|
||||
{ops.map((op, key) => (<div
|
||||
className={classNames('operation-proofread-item', {selected: op.proofread.id == currentIndex})}
|
||||
key={key}
|
||||
onClick={() => setSelectIndex(Number(op.proofread.id))}
|
||||
>
|
||||
<div className="proofread-item-container">
|
||||
<div className="proofread-data">
|
||||
<span className="origin">{op.proofread.origin}</span>
|
||||
<span className="icon"><IconArrowRight/></span>
|
||||
<span className="text">{op.proofread.text}</span>
|
||||
</div>
|
||||
<div className="proofread-description">{op.proofread.description}</div>
|
||||
</div>
|
||||
<div className="proofread-action">
|
||||
<ProofreadPopover content={<span>选用</span>}>
|
||||
<IconCheckFill className="process"/>
|
||||
</ProofreadPopover>
|
||||
<ProofreadPopover content={<span>忽略</span>}>
|
||||
<IconCancelFill className="cancel"/>
|
||||
</ProofreadPopover>
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content">{JSON.stringify(content)}</div>
|
||||
</div>)
|
||||
}
|
||||
export default WangEditor;
|
66
src/types/editor.ts
Normal file
66
src/types/editor.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export type ReplaceItemAttribute = {
|
||||
processed?: 'check' | 'cancel';
|
||||
origin: string;
|
||||
text?: string;
|
||||
/**
|
||||
* 地名纠错 成语纠错
|
||||
*/
|
||||
type: 'words' | 'address' | 'delete' | string;
|
||||
id?: number;
|
||||
}
|
||||
export type EditorFormat = {
|
||||
bold?: boolean;
|
||||
underline?: boolean;
|
||||
italic?: boolean;
|
||||
strike?: boolean;
|
||||
indent?: number;
|
||||
link?: string;
|
||||
align?: 'center' | 'right' | 'justify';
|
||||
replace?: ReplaceItemAttribute
|
||||
}
|
||||
type EditorContentItem = {
|
||||
insert?: string;
|
||||
retain?: number;
|
||||
delete?: number;
|
||||
attributes?: EditorFormat;
|
||||
}
|
||||
export type EditorContent = {
|
||||
ops: EditorContentItem[]
|
||||
}
|
||||
// type TextFontAttrs = 'bold' | 'italic' | 'underline' | 'through'
|
||||
export type WangEditorTextItem = {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
through?: boolean;
|
||||
fontSize?: string
|
||||
fontFamily?: string
|
||||
color?: string
|
||||
}
|
||||
type ProofreadType = 'words' | 'address' | 'delete' | 'insert';
|
||||
export const PROOFREAD_TYPE_DESC: {
|
||||
[key in ProofreadType]: string
|
||||
} = {
|
||||
words: '别字,建议选用"%text%"',
|
||||
address: '地名错误,建议选用"%text%"',
|
||||
delete: '多余内容,建议删除',
|
||||
insert: '新增,插入"%text%"',
|
||||
}
|
||||
|
||||
export type WangEditorProofreadElement = {
|
||||
type: 'proofread';
|
||||
proofread: {
|
||||
origin: string;
|
||||
text: string;
|
||||
type: ProofreadType;
|
||||
description?: string;
|
||||
id?: number;
|
||||
},
|
||||
children: WangEditorTextItem[]
|
||||
}
|
||||
export type WangEditorContentChildren = WangEditorTextItem | WangEditorProofreadElement
|
||||
export type WangEditorContent = {
|
||||
type: 'paragraph' | string;
|
||||
children: WangEditorContentChildren[];
|
||||
}
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': '/src/'
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 8088
|
||||
}
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user