This commit is contained in:
LittleBoy 2023-08-04 23:50:54 +08:00
commit 738d23ca9d
38 changed files with 5348 additions and 0 deletions

14
.eslintrc.cjs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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

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

View File

@ -0,0 +1,5 @@
import {Viewer} from "@textbus/platform-browser";
import {editorRootComponent, editorRootComponentLoader} from "./components.tsx";
export const Editor = new Viewer(editorRootComponent,editorRootComponentLoader);

View 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;
`
)}
/>
)
)

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

View 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}
/>
)
}

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

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

View 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>)
}

View 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;

View 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;

View 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;

View 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>)
}

View 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);

View 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
View 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
View 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>,
)

View 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
View 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

View 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>)

View 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)

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

View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

24
tsconfig.json Normal file
View 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
View 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
View 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
}
})

3280
yarn.lock Normal file

File diff suppressed because it is too large Load Diff