commit 306d7765ec43bdb6e81a4363e4fb720e57b32f90 Author: fantasticit Date: Sun Feb 20 19:51:55 2022 +0800 root: upload source code diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..fea3dc55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules +.DS_Store +.idea + +dist +dist-ssr +coverage +test-results +.pnpm-store +.npmrc +tsconfig.tsbuildinfo + +.env +*.local +*.cache +*error.log +*debug.log + +packages/config/yaml/prod.yaml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..beab0e46 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# think + +## 简介 + +Think 是一款开源知识管理工具。通过独立的知识库空间,结构化地组织在线协作文档,实现知识的积累与沉淀,促进知识的复用与流通。同时支持多人协作文档。使用的技术如下: + +- `MySQL`:数据存储 +- `next.js`:前端页面框架 +- `nest.js`:服务端框架 +- `AliyunOSS`:对象存储 +- `tiptap`:编辑器及文档协作 + +可访问[云策文档帮助中心](https://think.wipi.tech/share/wiki/4e3d0cfb-b169-4308-8037-e7d3df996af3),查看更多功能文档。 + +## 链接 + +[云策文档](https://think.wipi.tech/)已经部署上线,可前往注册使用。 + +## 项目运行 + +本项目依赖 pnpm 使用 monorepo 形式进行代码组织,分包如下: + +- `@think/config`: 管理项目整体配置 +- `@think/share`:数据类型定义、枚举、配置等 +- `@think/server`:服务端 +- `@think/client`:客户端 + +### pnpm + +项目依赖 pnpm,请安装后运行(`npm i -g pnpm`)。 + +### 数据库 + +首先安装 `MySQL`,推荐使用 docker 进行安装。 + +```bash +docker image pull mysql:5.7 +docker run -d --restart=always --name think -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root mysql:5.7 +``` + +然后在 `MySQL` 中创建数据库。 + +```bash +docker container exec -it think bash; +mysql -u root -p; +CREATE DATABASE `think` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +### 本地运行 + +首先,clone 项目。 + +```bash +git clone --depth=1 https://github.com/fantasticit/think.git your-project-name +``` + +然后,安装项目依赖。 + +```bash +pnpm install +``` + +- 启动项目 + +```bash +pnpm run dev +``` + +前台页面地址:`http://localhost:3000`。 +服务接口地址:`http://localhost:5001`。 +协作接口地址:`http://localhost:5003`。 + +如需修改配置,可在 `packages/config/yaml` 下进行配置。 + +### 配置文件 + +默认加载 `dev.yaml` 中的配置(生产环境使用 `prod.yaml` )。 + +```yaml +# 开发环境配置 +server: + prefix: "/api" + port: 5001 + collaborationPort: 5003 + +client: + assetPrefix: "/" + apiUrl: "http://localhost:5001/api" + collaborationUrl: "ws://localhost:5003" + +# 数据库配置 +db: + mysql: + host: "127.0.0.1" + username: "root" + password: "root" + database: "think" + port: 3306 + charset: "utf8mb4" + timezone: "+08:00" + synchronize: true + +# oss 文件存储服务 +oss: + aliyun: + accessKeyId: "" + accessKeySecret: "" + bucket: "" + https: true + region: "" + +# jwt 配置 +jwt: + secretkey: "zA_Think+KNOWLEDGE+WIKI+DOCUMENTS@2022" + expiresIn: "6h" +``` + +### 项目部署 + +生产环境部署的脚本如下: + +```bash + +node -v +npm -v + +npm config set registry http://registry.npmjs.org + +npm i -g pm2 @nestjs/cli pnpm + +pnpm install +pnpm run build +pnpm run pm2 + +pm2 startup +pm2 save +``` + +### nginx 配置 + +采用反向代理进行 `nginx` 配置,**同时设置 `proxy_set_header X-Real-IP $remote_addr;` 以便服务端获取到真实 ip 地址**。 + +```bash +upstream wipi_client { + server 127.0.0.1:3000; + keepalive 64; +} + +# http -> https 重定向 +server { + listen 80; + server_name 域名; + rewrite ^(.*)$ https://$host$1 permanent; +} + +server { + listen 443 ssl; + server_name 域名; + ssl_certificate 证书存放路径; + ssl_certificate_key 证书存放路径; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Nginx-Proxy true; + proxy_cache_bypass $http_upgrade; + proxy_pass http://wipi_client; #反向代理 + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +## 资料 + +- next.js 源码:https://github.com/vercel/next.js +- next.js 文档:https://nextjs.org/ +- nest.js 源码:https://github.com/nestjs/nest +- nest.js 文档:https://nestjs.com/ diff --git a/package.json b/package.json new file mode 100644 index 00000000..ac60ab2e --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "think", + "private": true, + "author": "fantasticit", + "scripts": { + "clean": "rimraf node_modules && rimraf ./**/node_modules", + "dev": "concurrently \"pnpm:dev:*\"", + "dev:server": "pnpm run --dir packages/server dev", + "dev:client": "pnpm run --dir packages/client dev", + "build": "pnpm build:share && pnpm build:server && pnpm build:client", + "build:config": "pnpm run --dir packages/config build", + "build:share": "pnpm run --dir packages/share build", + "build:server": "pnpm run --dir packages/server build", + "build:client": "pnpm run --dir packages/client build", + "start": "concurrently \"pnpm:start:*\"", + "start:server": "pnpm run --dir packages/server start", + "start:client": "pnpm run --dir packages/client start", + "pm2": "concurrently \"pnpm:pm2:*\"", + "pm2:server": "pnpm run --dir packages/server pm2", + "pm2:client": "pnpm run --dir packages/client pm2" + }, + "dependencies": { + "concurrently": "^7.0.0", + "cross-env": "^7.0.3", + "fs-extra": "^10.0.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=16.5.0" + }, + "devDependencies": { + "typescript": "^4.5.5" + } +} diff --git a/packages/client/.eslintrc.json b/packages/client/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/packages/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/packages/client/.gitignore b/packages/client/.gitignore new file mode 100644 index 00000000..88b6f0d9 --- /dev/null +++ b/packages/client/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 00000000..c87e0421 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/packages/client/next-env.d.ts b/packages/client/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/packages/client/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/client/next.config.js b/packages/client/next.config.js new file mode 100644 index 00000000..ddd073d3 --- /dev/null +++ b/packages/client/next.config.js @@ -0,0 +1,23 @@ +const semi = require("@douyinfe/semi-next").default({}); +const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); +const { getConfig } = require("@think/config"); +const config = getConfig().client; + +/** @type {import('next').NextConfig} */ +const nextConfig = semi({ + reactStrictMode: true, + assetPrefix: config.assetPrefix, + env: { + SERVER_API_URL: config.apiUrl, + COLLABORATION_API_URL: config.collaborationUrl, + }, + webpack: (config, { dev, isServer }) => { + config.resolve.plugins.push(new TsconfigPathsPlugin()); + return config; + }, + eslint: { + ignoreDuringBuilds: true, + }, +}); + +module.exports = nextConfig; diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 00000000..cb009c5f --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,82 @@ +{ + "name": "@think/client", + "private": true, + "scripts": { + "dev": "next dev", + "prebuild": "rimraf .next", + "build": "next build", + "start": "cross-env NODE_ENV=production next start -p 5002", + "lint": "next lint", + "pm2": "pm2 start npm --name @think/client -- start" + }, + "dependencies": { + "@douyinfe/semi-icons": "^2.3.1", + "@douyinfe/semi-next": "^2.3.1", + "@douyinfe/semi-ui": "^2.3.1", + "@hocuspocus/provider": "^1.0.0-alpha.29", + "@think/config": "workspace:^1.0.0", + "@think/share": "workspace:^1.0.0", + "@tiptap/core": "^2.0.0-beta.171", + "@tiptap/extension-blockquote": "^2.0.0-beta.26", + "@tiptap/extension-bold": "^2.0.0-beta.25", + "@tiptap/extension-bullet-list": "^2.0.0-beta.26", + "@tiptap/extension-code": "^2.0.0-beta.26", + "@tiptap/extension-code-block": "^2.0.0-beta.37", + "@tiptap/extension-code-block-lowlight": "^2.0.0-beta.68", + "@tiptap/extension-collaboration": "^2.0.0-beta.33", + "@tiptap/extension-collaboration-cursor": "^2.0.0-beta.34", + "@tiptap/extension-color": "^2.0.0-beta.9", + "@tiptap/extension-document": "^2.0.0-beta.15", + "@tiptap/extension-dropcursor": "^2.0.0-beta.25", + "@tiptap/extension-gapcursor": "^2.0.0-beta.34", + "@tiptap/extension-hard-break": "^2.0.0-beta.30", + "@tiptap/extension-heading": "^2.0.0-beta.26", + "@tiptap/extension-highlight": "^2.0.0-beta.33", + "@tiptap/extension-horizontal-rule": "^2.0.0-beta.31", + "@tiptap/extension-image": "^2.0.0-beta.25", + "@tiptap/extension-italic": "^2.0.0-beta.25", + "@tiptap/extension-link": "^2.0.0-beta.36", + "@tiptap/extension-list-item": "^2.0.0-beta.20", + "@tiptap/extension-ordered-list": "^2.0.0-beta.27", + "@tiptap/extension-paragraph": "^2.0.0-beta.23", + "@tiptap/extension-placeholder": "^2.0.0-beta.47", + "@tiptap/extension-strike": "^2.0.0-beta.27", + "@tiptap/extension-table": "^2.0.0-beta.48", + "@tiptap/extension-table-cell": "^2.0.0-beta.20", + "@tiptap/extension-table-header": "^2.0.0-beta.22", + "@tiptap/extension-table-row": "^2.0.0-beta.19", + "@tiptap/extension-task-item": "^2.0.0-beta.31", + "@tiptap/extension-task-list": "^2.0.0-beta.26", + "@tiptap/extension-text": "^2.0.0-beta.15", + "@tiptap/extension-text-align": "^2.0.0-beta.29", + "@tiptap/extension-text-style": "^2.0.0-beta.23", + "@tiptap/extension-underline": "^2.0.0-beta.23", + "@tiptap/react": "^2.0.0-beta.107", + "axios": "^0.25.0", + "classnames": "^2.3.1", + "copy-to-clipboard": "^3.3.1", + "deep-equal": "^2.0.5", + "dompurify": "^2.3.5", + "interactjs": "^1.10.11", + "katex": "^0.15.2", + "lowlight": "^2.5.0", + "marked": "^4.0.12", + "next": "12.0.10", + "prosemirror-markdown": "^1.7.0", + "prosemirror-view": "^1.23.6", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-helmet": "^6.1.0", + "react-split-pane": "^0.1.92", + "swr": "^1.2.0", + "tippy.js": "^6.3.7" + }, + "devDependencies": { + "@types/node": "17.0.13", + "@types/react": "17.0.38", + "eslint": "8.8.0", + "eslint-config-next": "12.0.10", + "tsconfig-paths-webpack-plugin": "^3.5.2", + "typescript": "4.5.5" + } +} diff --git a/packages/client/public/favicon.ico b/packages/client/public/favicon.ico new file mode 100644 index 00000000..937b76a1 Binary files /dev/null and b/packages/client/public/favicon.ico differ diff --git a/packages/client/src/components/author.tsx b/packages/client/src/components/author.tsx new file mode 100644 index 00000000..c6f8f105 --- /dev/null +++ b/packages/client/src/components/author.tsx @@ -0,0 +1,20 @@ +import { Space, Typography } from "@douyinfe/semi-ui"; +import { IconLikeHeart } from "@douyinfe/semi-icons"; + +const { Text } = Typography; + +export const Author = () => { + return ( +
+ + + Develop by + + fantasticit + + with + + +
+ ); +}; diff --git a/packages/client/src/components/data-render/index.tsx b/packages/client/src/components/data-render/index.tsx new file mode 100644 index 00000000..27bf7fe3 --- /dev/null +++ b/packages/client/src/components/data-render/index.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Empty, Spin, Typography } from "@douyinfe/semi-ui"; + +type RenderProps = React.ReactNode | (() => React.ReactNode); + +interface IProps { + loading: boolean; + error: Error | null; + loadingContent?: RenderProps; + errorContent?: RenderProps; + normalContent: RenderProps; +} + +const { Text } = Typography; + +const defaultLoading = () => { + return ; +}; + +const defaultRenderError = (error) => { + return {(error && error.message) || "未知错误"}; +}; + +const runRender = (fn, ...args) => + typeof fn === "function" ? fn.apply(null, args) : fn; + +export const DataRender: React.FC = ({ + loading, + error, + loadingContent = defaultLoading, + errorContent = defaultRenderError, + normalContent, +}) => { + if (loading) { + return runRender(loadingContent); + } + + if (error) { + return runRender(errorContent, error); + } + + return runRender(normalContent); +}; diff --git a/packages/client/src/components/document-creator/index.tsx b/packages/client/src/components/document-creator/index.tsx new file mode 100644 index 00000000..867c2d77 --- /dev/null +++ b/packages/client/src/components/document-creator/index.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Button } from "@douyinfe/semi-ui"; +import { useToggle } from "hooks/useToggle"; +import { useQuery } from "hooks/useQuery"; +import { DocumentCreator as DocumenCreatorForm } from "components/document/create"; + +interface IProps { + onCreateDocument?: () => void; +} + +export const DocumentCreator: React.FC = ({ + onCreateDocument, + children, +}) => { + const { wikiId, docId } = useQuery<{ wikiId?: string; docId?: string }>(); + const [visible, toggleVisible] = useToggle(false); + + return ( + <> + {children || ( + + )} + {wikiId && ( + + )} + + ); +}; diff --git a/packages/client/src/components/document/actions/index.tsx b/packages/client/src/components/document/actions/index.tsx new file mode 100644 index 00000000..3e151ffe --- /dev/null +++ b/packages/client/src/components/document/actions/index.tsx @@ -0,0 +1,112 @@ +import React, { useCallback } from "react"; +import { Dropdown, Button, Typography, Space } from "@douyinfe/semi-ui"; +import { IconMore, IconStar, IconPlus } from "@douyinfe/semi-icons"; +import { DocumentLinkCopyer } from "components/document/link"; +import { DocumentDeletor } from "components/document/delete"; +import { DocumentCreator } from "components/document/create"; +import { DocumentStar } from "components/document/star"; +import { useToggle } from "hooks/useToggle"; + +interface IProps { + wikiId: string; + documentId: string; + onStar?: () => void; + onCreate?: () => void; + onDelete?: () => void; + onVisibleChange?: () => void; + showCreateDocument?: boolean; +} + +const { Text } = Typography; + +export const DocumentActions: React.FC = ({ + wikiId, + documentId, + onStar, + onCreate, + onDelete, + onVisibleChange, + showCreateDocument, + children, +}) => { + const [visible, toggleVisible] = useToggle(false); + + const prevent = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + return ( + <> + + {showCreateDocument && ( + + + + + 新建子文档 + + + + )} + + ( + { + toggleStar().then(onStar); + }} + > + + + {text} + + + )} + /> + + + + + + + + + + } + > + {children || ( + + + ); + + return ( + <> + + {collaborationUsers.map((user) => { + return ( + + + {user.name && user.name.charAt(0)} + + + ); + })} + + + + + toggleVisible(false)} + maskClosable={false} + style={{ maxWidth: "96vw" }} + footer={null} + > + + +
+ + + 邀请成功后,请将该链接发送给对方。 + + + + + +
+
+ + } + normalContent={() => ( + + + + + ( + handleDelete(document)} + > +
+ )} + /> +
+
+
+ + ); +}; diff --git a/packages/client/src/components/document/comments/comments/Item/index.module.scss b/packages/client/src/components/document/comments/comments/Item/index.module.scss new file mode 100644 index 00000000..c59ce273 --- /dev/null +++ b/packages/client/src/components/document/comments/comments/Item/index.module.scss @@ -0,0 +1,36 @@ +.wrap { + display: flex; + padding: 9px 0px 9px 0; + + + .wrap { + margin-top: 16px; + } + + .rightWrap { + flex: 1; + margin-left: 16px; + overflow: auto; + + > main { + margin: 10px 0; + color: var(--semi-color-text-0); + } + + > footer { + // height: 0; + // transition: all ease-in-out 0.3s; + + span { + cursor: pointer; + } + } + } + + // &:hover { + // .rightWrap { + // > footer { + // height: 40px; + // } + // } + // } +} diff --git a/packages/client/src/components/document/comments/comments/Item/index.tsx b/packages/client/src/components/document/comments/comments/Item/index.tsx new file mode 100644 index 00000000..fb487e03 --- /dev/null +++ b/packages/client/src/components/document/comments/comments/Item/index.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import type { IComment, IUser } from "@think/share"; +import { + Avatar, + Typography, + Space, + Popconfirm, + Skeleton, +} from "@douyinfe/semi-ui"; +import { IconUser } from "@douyinfe/semi-icons"; +import { LocaleTime } from "components/locale-time"; +import { useUser } from "data/user"; +import styles from "./index.module.scss"; + +interface IProps { + comment: IComment; + replyComment: (comment: IComment) => void; + editComment: (comment: IComment) => void; + deleteComment: (comment: IComment) => void; +} + +const { Text } = Typography; + +export const CommentItem: React.FC = ({ + comment, + replyComment, + editComment, + deleteComment, +}) => { + if (!comment) return null; + const { user } = useUser(); + const { createUser = {} } = comment; + + return ( +
+
+ + + +
+
+
+ + {(createUser as IUser).name} + + + + +
+
+
+
+
+ + replyComment(comment)} + > + 回复 + + {user && user.id === comment.createUserId && ( + editComment(comment)} + > + 编辑 + + )} + deleteComment(comment)} + > + + 删除 + + + +
+
+
+ ); +}; + +export const CommentItemPlaceholder = () => { + return ( +
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ ); +}; diff --git a/packages/client/src/components/document/comments/comments/index.tsx b/packages/client/src/components/document/comments/comments/index.tsx new file mode 100644 index 00000000..1cd759aa --- /dev/null +++ b/packages/client/src/components/document/comments/comments/index.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import type { IComment } from "@think/share"; +import { CommentItem } from "./Item"; + +interface IProps { + comments: Array; + replyComment: (comment: IComment) => void; + editComment: (comment: IComment) => void; + deleteComment: (comment: IComment) => void; +} + +const PADDING_LEFT = 32; + +const CommentInner = ({ + data, + depth, + replyComment, + editComment, + deleteComment, +}) => { + return ( +
0 ? PADDING_LEFT : 0 }} + > + {(data || []).map((item) => { + const hasChildren = item.children && item.children.length; + return ( + <> + + {hasChildren ? ( + + ) : null} + + ); + })} +
+ ); +}; + +export const Comments: React.FC = ({ + comments, + replyComment, + editComment, + deleteComment, +}) => { + return ( + + ); +}; diff --git a/packages/client/src/components/document/comments/index.module.scss b/packages/client/src/components/document/comments/index.module.scss new file mode 100644 index 00000000..104267fb --- /dev/null +++ b/packages/client/src/components/document/comments/index.module.scss @@ -0,0 +1,43 @@ +.commentsWrap { + padding: 16px 0; + border-bottom: 1px solid var(--semi-color-border); + + .paginationWrap { + display: flex; + justify-content: center; + padding: 16px 0; + } +} + +.editorOuterWrap { + padding-top: 24px; + display: flex; + + .rightWrap { + flex: 1; + margin-left: 16px; + overflow: auto; + + .placeholderWrap { + padding: 12px 16px; + border: 1px solid var(--semi-color-border); + border-radius: var(--semi-border-radius-small); + } + + .editorWrap { + padding: 12px 16px; + border: 1px solid var(--semi-color-border); + border-radius: var(--semi-border-radius-small); + + .innerWrap { + max-height: 240px; + padding: 16px 0; + overflow: auto; + } + } + + .btnWrap { + margin-top: 16px; + } + } +} diff --git a/packages/client/src/components/document/comments/index.tsx b/packages/client/src/components/document/comments/index.tsx new file mode 100644 index 00000000..0d4bd34c --- /dev/null +++ b/packages/client/src/components/document/comments/index.tsx @@ -0,0 +1,215 @@ +import React, { useRef, useState } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { + Avatar, + Button, + Space, + Typography, + Banner, + Pagination, +} from "@douyinfe/semi-ui"; +import { useToggle } from "hooks/useToggle"; +import { useClickOutside } from "hooks/use-click-outside"; +import { DEFAULT_EXTENSION, Document, CommentMenuBar } from "components/tiptap"; +import { DataRender } from "components/data-render"; +import { useUser } from "data/user"; +import { useComments } from "data/comment"; +import { Comments } from "./comments"; +import { CommentItemPlaceholder } from "./comments/Item"; +import styles from "./index.module.scss"; + +interface IProps { + documentId: string; +} + +const { Text, Paragraph } = Typography; + +export const CommentEditor: React.FC = ({ documentId }) => { + const { user } = useUser(); + const { + data: commentsData, + loading, + error, + setPage, + addComment, + updateComment, + deleteComment, + } = useComments(documentId); + const [isEdit, toggleIsEdit] = useToggle(false); + const $container = useRef(); + const [replyComment, setReplyComment] = useState(null); + const [editComment, setEditComment] = useState(null); + + useClickOutside($container, { + out: () => isEdit && toggleIsEdit(false), + }); + + const editor = useEditor({ + editable: true, + extensions: [...DEFAULT_EXTENSION, Document], + }); + + const openEditor = () => { + toggleIsEdit(true); + editor.chain().focus(); + }; + + const handleClose = () => { + setReplyComment(null); + setEditComment(null); + toggleIsEdit(false); + }; + + const save = () => { + const html = editor.getHTML(); + + if (editComment) { + return updateComment({ + id: editComment.id, + html, + }).then(() => { + editor.commands.clearNodes(); + editor.commands.clearContent(); + handleClose(); + }); + } + + return addComment({ + html, + parentCommentId: (replyComment && replyComment.id) || null, + replyUserId: (replyComment && replyComment.createUserId) || null, + }).then(() => { + editor.commands.clearNodes(); + editor.commands.clearContent(); + handleClose(); + }); + }; + + const handleReplyComment = (comment) => { + setReplyComment(comment); + setEditComment(null); + openEditor(); + }; + + const handleEditComment = (comment) => { + setReplyComment(null); + setEditComment(comment); + openEditor(); + }; + + return ( +
+ + {Array.from({ length: 5 }, (_, i) => i).map((i) => ( + + ))} + + } + normalContent={() => ( + <> + {commentsData.total > 0 && ( + + 评论 + + )} + {commentsData.total > 0 && ( +
+ +
+ +
+
+ )} + + )} + /> + {replyComment && replyComment.createUser && ( + 回复评论: {replyComment.createUser.name}} + description={ + +
+
+ } + onClose={handleClose} + /> + )} + {editComment && ( + 编辑评论} + description={ + +
+
+ } + onClose={handleClose} + /> + )} +
+
+ {user && ( + + {user.name.charAt(0)} + + )} +
+ +
+ {isEdit ? ( + <> +
+
+ +
+
+ +
+
+
+ + + + +
+ + ) : ( +
+ 写下评论... +
+ )} +
+
+
+ ); +}; diff --git a/packages/client/src/components/document/create/index.module.scss b/packages/client/src/components/document/create/index.module.scss new file mode 100644 index 00000000..f6ca0379 --- /dev/null +++ b/packages/client/src/components/document/create/index.module.scss @@ -0,0 +1,3 @@ +.isActive { + border: 1px solid var(--semi-color-link); +} diff --git a/packages/client/src/components/document/create/index.tsx b/packages/client/src/components/document/create/index.tsx new file mode 100644 index 00000000..4868a019 --- /dev/null +++ b/packages/client/src/components/document/create/index.tsx @@ -0,0 +1,118 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from "react"; +import Router from "next/router"; +import { Modal, Tabs, TabPane, Checkbox } from "@douyinfe/semi-ui"; +import { useCreateDocument } from "data/document"; +import { usePublicTemplates, useOwnTemplates } from "data/template"; +import { TemplateList } from "components/template/list"; +import { TemplateCardEmpty } from "components/template/card"; + +import styles from "./index.module.scss"; + +interface IProps { + wikiId: string; + parentDocumentId?: string; + visible: boolean; + toggleVisible: Dispatch>; + onCreate?: () => void; +} + +export const DocumentCreator: React.FC = ({ + wikiId, + parentDocumentId, + visible, + toggleVisible, + onCreate, +}) => { + const { loading, create } = useCreateDocument(); + const [createChildDoc, setCreateChildDoc] = useState(false); + const [templateId, setTemplateId] = useState(""); + + const handleOk = () => { + const data = { + wikiId, + parentDocumentId: createChildDoc ? parentDocumentId : null, + templateId, + }; + create(data).then((res) => { + toggleVisible(false); + onCreate && onCreate(); + setTemplateId(""); + Router.push({ + pathname: `/wiki/${wikiId}/document/${res.id}/edit`, + }); + }); + }; + const handleCancel = useCallback(() => { + toggleVisible(false); + }, [toggleVisible]); + + useEffect(() => { + setCreateChildDoc(!!parentDocumentId); + }, [parentDocumentId]); + + return ( + + setCreateChildDoc(e.target.checked)} + > + 为该文档创建子文档 + + ) + } + > + + setTemplateId(id)} + getClassNames={(id) => id === templateId && styles.isActive} + firstListItem={ + !templateId && styles.isActive} + onClick={() => setTemplateId("")} + /> + } + /> + + + setTemplateId(id)} + getClassNames={(id) => id === templateId && styles.isActive} + firstListItem={ + !templateId && styles.isActive} + onClick={() => setTemplateId("")} + /> + } + /> + + + + ); +}; diff --git a/packages/client/src/components/document/delete/index.tsx b/packages/client/src/components/document/delete/index.tsx new file mode 100644 index 00000000..7adc9fec --- /dev/null +++ b/packages/client/src/components/document/delete/index.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from "react"; +import Router from "next/router"; +import { Typography, Space, Modal } from "@douyinfe/semi-ui"; +import { IconDelete } from "@douyinfe/semi-icons"; +import { useDeleteDocument } from "data/document"; + +interface IProps { + wikiId: string; + documentId: string; + onDelete?: () => void; +} + +const { Text } = Typography; + +export const DocumentDeletor: React.FC = ({ + wikiId, + documentId, + onDelete, +}) => { + const { deleteDocument: api, loading } = useDeleteDocument(documentId); + + const deleteAction = useCallback(() => { + Modal.error({ + title: "确定删除吗?", + content: "文档删除后不可恢复!", + onOk: () => { + api().then(() => { + onDelete + ? onDelete() + : Router.push({ + pathname: `/wiki/${wikiId}`, + }); + }); + }, + okButtonProps: { loading, type: "danger" }, + style: { maxWidth: "96vw" }, + }); + }, [wikiId, documentId, api, loading, onDelete]); + + return ( + + + + 删除 + + + ); +}; diff --git a/packages/client/src/components/document/editor/editor.tsx b/packages/client/src/components/document/editor/editor.tsx new file mode 100644 index 00000000..e5780eb4 --- /dev/null +++ b/packages/client/src/components/document/editor/editor.tsx @@ -0,0 +1,106 @@ +import React, { useMemo, useEffect } from "react"; +import cls from "classnames"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { Layout, Nav, BackTop, Toast } from "@douyinfe/semi-ui"; +import { IUser, IAuthority } from "@think/share"; +import { useToggle } from "hooks/useToggle"; +import { + DEFAULT_EXTENSION, + DocumentWithTitle, + getCollaborationExtension, + getCollaborationCursorExtension, + getProvider, + destoryProvider, + MenuBar, + Toc, +} from "components/tiptap"; +import { DataRender } from "components/data-render"; +import { joinUser } from "components/document/collaboration"; +import styles from "./index.module.scss"; + +const { Header, Content } = Layout; + +interface IProps { + user: IUser; + documentId: string; + authority: IAuthority; + className: string; + style: React.CSSProperties; +} + +export const Editor: React.FC = ({ + user, + documentId, + authority, + className, + style, +}) => { + if (!user) return null; + + const provider = useMemo(() => { + return getProvider({ + targetId: documentId, + token: user.token, + cacheType: "EDITOR", + user, + docType: "document", + events: { + onAwarenessUpdate({ states }) { + joinUser({ states }); + }, + }, + }); + }, [documentId, user.token]); + const editor = useEditor({ + editable: authority && authority.editable, + extensions: [ + ...DEFAULT_EXTENSION, + DocumentWithTitle, + getCollaborationExtension(provider), + getCollaborationCursorExtension(provider, user), + ], + }); + const [loading, toggleLoading] = useToggle(true); + + useEffect(() => { + provider.on("synced", () => { + toggleLoading(false); + }); + + provider.on("status", async ({ status }) => { + console.log("status", status); + }); + + return () => { + destoryProvider(provider, "EDITOR"); + }; + }, []); + + return ( + { + return ( +
+
+
+ +
+
+
+
+ +
+ + document.querySelector("#js-template-editor-container") + } + /> +
+
+ ); + }} + /> + ); +}; diff --git a/packages/client/src/components/document/editor/index.module.scss b/packages/client/src/components/document/editor/index.module.scss new file mode 100644 index 00000000..4af1c57f --- /dev/null +++ b/packages/client/src/components/document/editor/index.module.scss @@ -0,0 +1,72 @@ +.wrap { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + + > header { + height: 60px; + > div { + overflow: auto; + } + } + + > main { + height: calc(100% - 60px); + flex: 1; + overflow: hidden; + background-color: var(--semi-color-nav-bg); + } +} + +.editorWrap { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + + > header { + height: 50px; + padding: 0 24px; + display: flex; + align-items: center; + overflow: hidden; + border-bottom: 1px solid var(--semi-color-border); + + &.isStandardWidth { + justify-content: center; + } + + &.isFullWidth { + justify-content: flex-start; + } + + > div { + display: inline-flex; + align-items: center; + height: 100%; + overflow: auto; + } + } + + > main { + flex: 1; + height: calc(100% - 50px); + overflow: auto; + + .contentWrap { + padding: 24px 24px 96px; + + &.isStandardWidth { + width: 96%; + max-width: 750px; + margin: 0 auto; + } + + &.isFullWidth { + width: 100%; + margin: 0 auto; + } + } + } +} diff --git a/packages/client/src/components/document/editor/index.tsx b/packages/client/src/components/document/editor/index.tsx new file mode 100644 index 00000000..ab191e8c --- /dev/null +++ b/packages/client/src/components/document/editor/index.tsx @@ -0,0 +1,153 @@ +import Router from "next/router"; +import React, { useCallback, useMemo } from "react"; +import { + Layout, + Nav, + Skeleton, + Typography, + Space, + Button, + Tooltip, + Spin, + Popover, +} from "@douyinfe/semi-ui"; +import { IconChevronLeft, IconArticle } from "@douyinfe/semi-icons"; +import { useUser } from "data/user"; +import { useDocumentDetail } from "data/document"; +import { Seo } from "components/seo"; +import { Theme } from "components/theme"; +import { DataRender } from "components/data-render"; +import { DocumentShare } from "components/document/share"; +import { DocumentStar } from "components/document/star"; +import { DocumentCollaboration } from "components/document/collaboration"; +import { DocumentStyle } from "components/document/style"; +import { useDocumentStyle } from "hooks/useDocumentStyle"; +import { Editor } from "./editor"; +import styles from "./index.module.scss"; + +const { Header, Content } = Layout; +const { Text } = Typography; + +interface IProps { + documentId: string; +} + +export const DocumentEditor: React.FC = ({ documentId }) => { + if (!documentId) return null; + + const { width, fontSize } = useDocumentStyle(); + const editorWrapClassNames = useMemo(() => { + return width === "standardWidth" + ? styles.isStandardWidth + : styles.isFullWidth; + }, [width]); + + const { user } = useUser(); + const { + data: documentAndAuth, + loading: docAuthLoading, + error: docAuthError, + } = useDocumentDetail(documentId); + const { document, authority } = documentAndAuth || {}; + + const goback = useCallback(() => { + Router.push({ + pathname: `/wiki/${document.wikiId}/document/${documentId}`, + }); + }, [document]); + + const DocumentTitle = ( + <> + + + )} + + toggleVisible(false)} + maskClosable={false} + style={{ maxWidth: "96vw" }} + > + { + return ( +
+
+ +
+ {isPublic ? ( + } + copyable={{ + onCopy: () => Toast.success({ content: "复制文本成功" }), + }} + style={{ + width: 320, + }} + > + {shareUrl} + + ) : ( + + )} +
+ + {isPublic + ? "分享开启后,该页面包含的所有内容均可访问,请谨慎开启" + : " 分享关闭后,其他人将不能继续访问该页面"} + +
+
+ ); + }} + /> +
+ + ); +}; diff --git a/packages/client/src/components/document/star/index.tsx b/packages/client/src/components/document/star/index.tsx new file mode 100644 index 00000000..cedfcc97 --- /dev/null +++ b/packages/client/src/components/document/star/index.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { Typography, Tooltip, Button } from "@douyinfe/semi-ui"; +import { IconStar } from "@douyinfe/semi-icons"; +import { useDocumentStar } from "data/document"; + +interface IProps { + documentId: string; + render?: (arg: { + star: boolean; + text: string; + toggleStar: () => Promise; + }) => React.ReactNode; +} + +const { Text } = Typography; + +export const DocumentStar: React.FC = ({ documentId, render }) => { + const { data, toggleStar } = useDocumentStar(documentId); + const text = data ? "取消收藏" : "收藏文档"; + + return ( + <> + {render ? ( + render({ star: data, toggleStar, text }) + ) : ( + + +
+ ); +}; diff --git a/packages/client/src/components/resizeable/index.tsx b/packages/client/src/components/resizeable/index.tsx new file mode 100644 index 00000000..66a4609a --- /dev/null +++ b/packages/client/src/components/resizeable/index.tsx @@ -0,0 +1 @@ +export * from "./resizeable"; diff --git a/packages/client/src/components/resizeable/resizeable.tsx b/packages/client/src/components/resizeable/resizeable.tsx new file mode 100644 index 00000000..fbe8668f --- /dev/null +++ b/packages/client/src/components/resizeable/resizeable.tsx @@ -0,0 +1,92 @@ +import React, { useRef, useEffect } from "react"; +import { useClickOutside } from "hooks/use-click-outside"; +import interact from "interactjs"; +import styles from "./style.module.scss"; + +interface IProps { + width: number; + height: number; + onChange: (arg: { width: number; height: number }) => void; +} + +const MIN_WIDTH = 50; +const MIN_HEIGHT = 50; + +export const Resizeable: React.FC = ({ + width, + height, + onChange, + children, +}) => { + const $container = useRef(null); + const $topLeft = useRef(null); + const $topRight = useRef(null); + const $bottomLeft = useRef(null); + const $bottomRight = useRef(null); + + useClickOutside($container, { + in: () => $container.current.classList.add(styles.isActive), + out: () => $container.current.classList.remove(styles.isActive), + }); + + useEffect(() => { + interact($container.current).resizable({ + edges: { + top: true, + right: true, + bottom: true, + left: true, + }, + listeners: { + move: function (event) { + let { x, y } = event.target.dataset; + x = (parseFloat(x) || 0) + event.deltaRect.left; + y = (parseFloat(y) || 0) + event.deltaRect.top; + + let { width, height } = event.rect; + width = width < MIN_WIDTH ? MIN_WIDTH : width; + height = height < MIN_HEIGHT ? MIN_HEIGHT : height; + + Object.assign(event.target.style, { + width: `${width}px`, + height: `${height}px`, + // transform: `translate(${x}px, ${y}px)`, + }); + Object.assign(event.target.dataset, { x, y }); + onChange && onChange({ width, height }); + }, + }, + }); + }, []); + + return ( +
+ + + + + {children} +
+ ); +}; diff --git a/packages/client/src/components/resizeable/style.module.scss b/packages/client/src/components/resizeable/style.module.scss new file mode 100644 index 00000000..9ac55ce0 --- /dev/null +++ b/packages/client/src/components/resizeable/style.module.scss @@ -0,0 +1,52 @@ +.resizable { + box-sizing: border-box; + position: relative; + display: inline-block; + width: 100px; + height: 100px; + max-width: 100%; + + .resizer { + box-sizing: border-box; + position: absolute; + z-index: 9999; + width: 10px; + height: 10px; + border-radius: 50%; + background: white; + border: 3px solid #4286f4; + opacity: 0; + } + + .resizer.topLeft { + left: -5px; + top: -5px; + cursor: nwse-resize; + } + + .resizer.topRight { + right: -5px; + top: -5px; + cursor: nesw-resize; + } + + .resizer.bottomLeft { + left: -5px; + bottom: -5px; + cursor: nesw-resize; + } + + .resizer.bottomRight { + right: -5px; + bottom: -5px; + cursor: nwse-resize; + } + + &.isActive { + border: 1px solid #4286f4; + + .resizer { + opacity: 1; + } + } +} diff --git a/packages/client/src/components/seo.tsx b/packages/client/src/components/seo.tsx new file mode 100644 index 00000000..3e4a5883 --- /dev/null +++ b/packages/client/src/components/seo.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Helmet } from "react-helmet"; + +interface IProps { + title: string; + needTitleSuffix?: boolean; +} + +const buildTitle = (title) => `${title} - 云策文档`; + +export const Seo: React.FC = ({ title, needTitleSuffix = true }) => { + return ( + + {needTitleSuffix ? buildTitle(title) : title} + + + + + ); +}; diff --git a/packages/client/src/components/template/card/index.module.scss b/packages/client/src/components/template/card/index.module.scss new file mode 100644 index 00000000..e7a13f29 --- /dev/null +++ b/packages/client/src/components/template/card/index.module.scss @@ -0,0 +1,67 @@ +.cardWrap { + position: relative; + transform: translateZ(0); + margin: 8px 0; + display: flex; + flex-direction: column; + width: 100%; + height: 161px; + padding: 12px 16px 16px; + border-radius: var(--border-radius); + border: 1px solid var(--semi-color-border); + cursor: pointer; + overflow: hidden; + + header { + display: flex; + justify-content: space-between; + align-items: center; + + color: var(--semi-color-primary); + margin-bottom: 12px; + + .rightWrap { + opacity: 0; + } + } + + &:hover { + box-shadow: var(--semi-color-shadow); + + header .rightWrap { + opacity: 1; + } + } + + footer { + margin-top: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .actions { + position: absolute; + bottom: -1px; + left: 0; + z-index: 10; + transition: all ease-in-out 0.2s; + width: 100%; + display: flex; + justify-content: space-around; + padding: 8px; + border-radius: 0 0 var(--border-radius) var(--border-radius); + background-color: var(--semi-color-fill-2); + opacity: 0; + + button { + width: 40%; + } + } + + &:hover { + .actions { + opacity: 1; + } + } +} diff --git a/packages/client/src/components/template/card/index.tsx b/packages/client/src/components/template/card/index.tsx new file mode 100644 index 00000000..d9527c96 --- /dev/null +++ b/packages/client/src/components/template/card/index.tsx @@ -0,0 +1,203 @@ +import type { ITemplate } from "@think/share"; +import { useCallback } from "react"; +import cls from "classnames"; +import Router from "next/router"; +import { + Button, + Space, + Typography, + Tooltip, + Avatar, + Skeleton, + Modal, +} from "@douyinfe/semi-ui"; +import { IconEdit, IconUser, IconPlus } from "@douyinfe/semi-icons"; +import { IconDocument } from "components/icons/IconDocument"; +import { TemplateReader } from "components/template/reader"; +import styles from "./index.module.scss"; +import { useToggle } from "hooks/useToggle"; + +const { Text } = Typography; + +export interface IProps { + template: ITemplate; + onClick?: (id: string) => void; + getClassNames?: (id: string) => string; + onOpenPreview?: () => void; + onClosePreview?: () => void; +} + +export const TemplateCard: React.FC = ({ + template, + onClick, + getClassNames = (id) => "", + onOpenPreview, + onClosePreview, +}) => { + const [visible, toggleVisible] = useToggle(false); + + const gotoEdit = useCallback(() => { + Router.push(`/template/${template.id}/`); + }, [template]); + + return ( + <> + { + toggleVisible(false); + onClosePreview && onClosePreview(); + }} + footer={null} + fullScreen + > + + +
+
+ +
+ + +
+
+
+
+ {template.title} +
+
+ + + + + + 创建者: + {template.createUser && template.createUser.name} + + +
+
+
+ +
+ 已使用 + {template.usageAmount}次 +
+
+
+
+ + {onClick && ( + + )} +
+
+ + ); +}; + +export const TemplateCardPlaceholder = () => { + return ( +
+
+ +
+
+
+ +
+
+ + + + + + 创建者: + + + +
+
+
+ +
+ 更新时间: + +
+
+
+
+ ); +}; + +export const TemplateCardEmpty = ({ + getClassNames = () => "", + onClick = () => {}, +}) => { + return ( +
+
+
+ + + + 空白文档 +
+
+
+ ); +}; diff --git a/packages/client/src/components/template/editor/editor.tsx b/packages/client/src/components/template/editor/editor.tsx new file mode 100644 index 00000000..536858c5 --- /dev/null +++ b/packages/client/src/components/template/editor/editor.tsx @@ -0,0 +1,210 @@ +import React, { useMemo, useCallback, useState, useEffect } from "react"; +import Router from "next/router"; +import cls from "classnames"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { + Button, + Nav, + Space, + Skeleton, + Typography, + Tooltip, + Spin, + Switch, + Popover, + Popconfirm, + BackTop, +} from "@douyinfe/semi-ui"; +import { IconChevronLeft, IconArticle } from "@douyinfe/semi-icons"; +import { IUser, ITemplate } from "@think/share"; +import { Theme } from "components/theme"; +import { + DEFAULT_EXTENSION, + DocumentWithTitle, + getCollaborationExtension, + getProvider, + MenuBar, + Toc, +} from "components/tiptap"; +import { DataRender } from "components/data-render"; +import { User } from "components/user"; +import { DocumentStyle } from "components/document/style"; +import { useDocumentStyle } from "hooks/useDocumentStyle"; +import { safeJSONParse } from "helpers/json"; +import styles from "./index.module.scss"; + +const { Text } = Typography; + +interface IProps { + user: IUser; + data: ITemplate; + loading: boolean; + error: Error | null; + updateTemplate: (arg) => Promise; + deleteTemplate: () => Promise; +} + +export const Editor: React.FC = ({ + user, + data, + loading, + error, + updateTemplate, + deleteTemplate, +}) => { + if (!user) return null; + + const provider = useMemo(() => { + return getProvider({ + targetId: data.id, + token: user.token, + cacheType: "READER", + user, + docType: "template", + }); + }, [data, user.token]); + const editor = useEditor({ + editable: true, + extensions: [ + ...DEFAULT_EXTENSION, + DocumentWithTitle, + getCollaborationExtension(provider), + ], + content: safeJSONParse(data && data.content), + }); + + const [isPublic, setPublic] = useState(false); + const { width, fontSize } = useDocumentStyle(); + const editorWrapClassNames = useMemo(() => { + return width === "standardWidth" + ? styles.isStandardWidth + : styles.isFullWidth; + }, [width]); + + const goback = useCallback(() => { + Router.back(); + }, []); + + const handleDelte = useCallback(() => { + deleteTemplate().then(() => { + goback(); + }); + }, [deleteTemplate]); + + useEffect(() => { + if (!data) return; + setPublic(data.isPublic); + }, [data]); + + return ( +
+
+ +
+
+ + +
+ } + error={error} + normalContent={() => { + return ( +
+
+
+ +
+
+
+
+ +
+ + document.querySelector("#js-template-editor-container") + } + /> +
+
+ ); + }} + /> + + + ); +}; diff --git a/packages/client/src/components/template/editor/index.module.scss b/packages/client/src/components/template/editor/index.module.scss new file mode 100644 index 00000000..8b7b2c4b --- /dev/null +++ b/packages/client/src/components/template/editor/index.module.scss @@ -0,0 +1,77 @@ +.wrap { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + + > header { + height: 60px; + > div { + overflow: auto; + } + } + + > main { + height: calc(100% - 60px); + flex: 1; + overflow: hidden; + background-color: var(--semi-color-nav-bg); + } +} + +.editorWrap { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + + > header { + height: 50px; + padding: 0 24px; + display: flex; + align-items: center; + overflow: hidden; + border-bottom: 1px solid var(--semi-color-border); + + &.isStandardWidth { + > div { + margin: 0 auto; + } + } + + &.isFullWidth { + > div { + margin: 0; + } + } + + > div { + display: inline-flex; + align-items: center; + // width: 100%; + height: 100%; + overflow: auto; + } + } + + > main { + flex: 1; + height: calc(100% - 50px); + overflow: auto; + + .contentWrap { + padding: 24px 24px 96px; + + &.isStandardWidth { + width: 96%; + max-width: 750px; + margin: 0 auto; + } + + &.isFullWidth { + width: 100%; + margin: 0 auto; + } + } + } +} diff --git a/packages/client/src/components/template/editor/index.tsx b/packages/client/src/components/template/editor/index.tsx new file mode 100644 index 00000000..3e2293e9 --- /dev/null +++ b/packages/client/src/components/template/editor/index.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Spin } from "@douyinfe/semi-ui"; +import { useUser } from "data/user"; +import { Seo } from "components/seo"; +import { DataRender } from "components/data-render"; +import { useTemplate } from "data/template"; +import { Editor } from "./editor"; + +interface IProps { + templateId: string; +} + +export const TemplateEditor: React.FC = ({ templateId }) => { + const { user } = useUser(); + const { data, loading, error, updateTemplate, deleteTemplate } = + useTemplate(templateId); + + return ( + + + + } + error={error} + normalContent={() => { + return ( + <> + + + + ); + }} + /> + ); +}; diff --git a/packages/client/src/components/template/list/index.tsx b/packages/client/src/components/template/list/index.tsx new file mode 100644 index 00000000..ff473665 --- /dev/null +++ b/packages/client/src/components/template/list/index.tsx @@ -0,0 +1,104 @@ +import React, { useState, useMemo } from "react"; +import { List, Pagination } from "@douyinfe/semi-ui"; +import { DataRender } from "components/data-render"; +import { + IProps as ITemplateCardProps, + TemplateCardPlaceholder, + TemplateCard, +} from "components/template/card"; +import { Empty } from "components/empty"; + +const grid = { + gutter: 16, + xs: 24, + sm: 12, + md: 12, + lg: 8, + xl: 8, +}; + +interface IProps extends Omit { + // TODO: 修复类型 + hook: any; + firstListItem?: React.ReactNode; + pageSize?: number; +} + +export const TemplateList: React.FC = ({ + hook, + onClick, + getClassNames, + firstListItem, + onOpenPreview, + onClosePreview, + pageSize = 5, +}) => { + const { data, loading, error } = hook(); + + const [page, onPageChange] = useState(1); + + const arr = useMemo(() => { + const arr = (data && data.data) || []; + const start = (page - 1) * pageSize; + const end = page * pageSize; + return arr.slice(start, end); + }, [data, page]); + + return ( + ( + ( + + + + )} + /> + )} + error={error} + normalContent={() => ( + <> + { + if (idx === 0 && firstListItem) { + return {firstListItem}; + } + + return ( + + + + ); + }} + emptyContent={} + > + {data.data.length > pageSize ? ( + onPageChange(cPage)} + /> + ) : null} + + )} + /> + ); +}; diff --git a/packages/client/src/components/template/reader/editor.tsx b/packages/client/src/components/template/reader/editor.tsx new file mode 100644 index 00000000..5ac79972 --- /dev/null +++ b/packages/client/src/components/template/reader/editor.tsx @@ -0,0 +1,73 @@ +import React, { useMemo } from "react"; +import cls from "classnames"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { Layout, Spin, Typography } from "@douyinfe/semi-ui"; +import { IUser, ITemplate } from "@think/share"; +import { DEFAULT_EXTENSION, DocumentWithTitle } from "components/tiptap"; +import { DataRender } from "components/data-render"; +import { useDocumentStyle } from "hooks/useDocumentStyle"; +import { safeJSONParse } from "helpers/json"; +import styles from "./index.module.scss"; + +const { Content } = Layout; +const { Title } = Typography; + +interface IProps { + user: IUser; + data: ITemplate; + loading: boolean; + error: Error | null; +} + +export const Editor: React.FC = ({ user, data, loading, error }) => { + if (!user) return null; + + const c = safeJSONParse(data.content); + let json = c.default || c; + + if (json && json.content) { + json = { + type: "doc", + content: json.content.slice(1), + }; + } + + const editor = useEditor({ + editable: false, + extensions: [...DEFAULT_EXTENSION, DocumentWithTitle], + content: json, + }); + + const { width, fontSize } = useDocumentStyle(); + const editorWrapClassNames = useMemo(() => { + return width === "standardWidth" + ? styles.isStandardWidth + : styles.isFullWidth; + }, [width]); + + return ( +
+ + + +
+ } + error={error} + normalContent={() => { + return ( + +
+ {data.title} + +
+
+ ); + }} + /> + + + ); +}; diff --git a/packages/client/src/components/template/reader/index.module.scss b/packages/client/src/components/template/reader/index.module.scss new file mode 100644 index 00000000..1117e301 --- /dev/null +++ b/packages/client/src/components/template/reader/index.module.scss @@ -0,0 +1,33 @@ +.wrap { + display: flex; + flex-direction: column; + + height: 100%; + + .contentWrap { + flex: 1; + overflow: hidden; + + > div { + display: flex; + flex-direction: column; + height: 100%; + } + + .editorWrap { + flex: 1; + overflow: auto; + } + } +} + +.isStandardWidth { + width: 96%; + max-width: 750px; + margin: 0 auto; +} + +.isFullWidth { + width: 100%; + margin: 0 auto; +} diff --git a/packages/client/src/components/template/reader/index.tsx b/packages/client/src/components/template/reader/index.tsx new file mode 100644 index 00000000..348a9e98 --- /dev/null +++ b/packages/client/src/components/template/reader/index.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Spin } from "@douyinfe/semi-ui"; +import { useUser } from "data/user"; +import { Seo } from "components/seo"; +import { DataRender } from "components/data-render"; +import { useTemplate } from "data/template"; +import { Editor } from "./editor"; + +interface IProps { + templateId: string; +} + +export const TemplateReader: React.FC = ({ templateId }) => { + const { user } = useUser(); + const { data, loading, error } = useTemplate(templateId); + + return ( + + + + } + error={error} + normalContent={() => { + return ( +
+ + +
+ ); + }} + /> + ); +}; diff --git a/packages/client/src/components/theme/index.tsx b/packages/client/src/components/theme/index.tsx new file mode 100644 index 00000000..08834f91 --- /dev/null +++ b/packages/client/src/components/theme/index.tsx @@ -0,0 +1,20 @@ +import React, { useEffect, useState } from "react"; +import { Button, Tooltip } from "@douyinfe/semi-ui"; +import { IconSun, IconMoon } from "@douyinfe/semi-icons"; +import { useTheme } from "hooks/useTheme"; + +export const Theme = () => { + const { theme, toggle } = useTheme(); + const Icon = theme === "dark" ? IconSun : IconMoon; + const text = theme === "dark" ? "切换到亮色模式" : "切换到深色模式"; + + return ( + + + + ); +}; diff --git a/packages/client/src/components/tiptap/base-kit.tsx b/packages/client/src/components/tiptap/base-kit.tsx new file mode 100644 index 00000000..2a89ad2f --- /dev/null +++ b/packages/client/src/components/tiptap/base-kit.tsx @@ -0,0 +1,110 @@ +import { Document, TitledDocument, Title } from "./extensions/title"; +import Placeholder from "@tiptap/extension-placeholder"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import Strike from "@tiptap/extension-strike"; +import Underline from "@tiptap/extension-underline"; +import TextStyle from "@tiptap/extension-text-style"; +import { Color } from "@tiptap/extension-color"; +import Blockquote from "@tiptap/extension-blockquote"; +import Bold from "@tiptap/extension-bold"; +import Code from "@tiptap/extension-code"; +import Highlight from "@tiptap/extension-highlight"; +import TextAlign from "@tiptap/extension-text-align"; +import Dropcursor from "@tiptap/extension-dropcursor"; +import Gapcursor from "@tiptap/extension-gapcursor"; +import HardBreak from "@tiptap/extension-hard-break"; +import Heading from "@tiptap/extension-heading"; +import Italic from "@tiptap/extension-italic"; +import OrderedList from "@tiptap/extension-ordered-list"; +import BulletList from "@tiptap/extension-bullet-list"; +import ListItem from "@tiptap/extension-list-item"; +import TaskList from "@tiptap/extension-task-list"; +import TaskItem from "@tiptap/extension-task-item"; +import { HorizontalRule } from "./extensions/horizontal-rule"; +import { BackgroundColor } from "./extensions/background-color"; +import { Link } from "./extensions/link"; +import { FontSize } from "./extensions/font-size"; +import { ColorHighlighter } from "./extensions/color-highlight"; +import { Indent } from "./extensions/indent"; +import { Div } from "./extensions/div"; +import { Banner } from "./extensions/banner"; +import { CodeBlock } from "./extensions/code-block"; +import { Iframe } from "./extensions/iframe"; +import { Mind } from "./extensions/mind"; +import { Image } from "./extensions/image"; +import { Status } from "./extensions/status"; +import { Paste } from "./extensions/paste"; +import { Table, TableRow, TableCell, TableHeader } from "./extensions/table"; +import { Toc } from "./extensions/toc"; +import { TrailingNode } from "./extensions/trailing-node"; +import { Attachment } from "./extensions/attachment"; +import { Katex } from "./extensions/katex"; +import { DocumentReference } from "./extensions/documents/reference"; +import { DocumentChildren } from "./extensions/documents/children"; + +export { Document, TitledDocument }; + +export const BaseExtension = [ + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "title") { + return "请输入标题"; + } + return "请输入内容"; + }, + showOnlyWhenEditable: true, + }), + Title, + Paragraph, + Text, + Strike, + Underline, + TextStyle, + Color, + BackgroundColor, + Bold, + Code, + Dropcursor, + Gapcursor, + HardBreak, + Heading, + HorizontalRule, + Italic, + OrderedList, + BulletList, + ListItem, + TaskList, + TaskItem.configure({ + nested: true, + }), + Highlight.configure({ multicolor: true }), + TextAlign.configure({ + types: ["heading", "paragraph", "image"], + }), + Link.configure({ openOnClick: false }), + Blockquote, + FontSize, + ColorHighlighter, + Indent, + CodeBlock, + Div, + Banner, + Iframe, + Mind, + Image, + Status, + Paste, + Table.configure({ + resizable: true, + }), + TableRow, + TableCell, + TableHeader, + Toc, + TrailingNode, + Attachment, + Katex, + DocumentReference, + DocumentChildren, +]; diff --git a/packages/client/src/components/tiptap/components/bubble-menu/bubble-menu-plugin.tsx b/packages/client/src/components/tiptap/components/bubble-menu/bubble-menu-plugin.tsx new file mode 100644 index 00000000..18bf66fb --- /dev/null +++ b/packages/client/src/components/tiptap/components/bubble-menu/bubble-menu-plugin.tsx @@ -0,0 +1,262 @@ +import { + Editor, + posToDOMRect, + isTextSelection, + isNodeSelection, +} from "@tiptap/core"; +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import tippy, { Instance, Props } from "tippy.js"; + +export interface BubbleMenuPluginProps { + pluginKey: PluginKey | string; + editor: Editor; + element: HTMLElement; + tippyOptions?: Partial; + shouldShow?: + | ((props: { + editor: Editor; + view: EditorView; + state: EditorState; + oldState?: EditorState; + from: number; + to: number; + }) => boolean) + | null; + renderContainerSelector?: string; + matchRenderContainer?: (node: HTMLElement) => boolean; +} + +export type BubbleMenuViewProps = BubbleMenuPluginProps & { + view: EditorView; +}; + +export class BubbleMenuView { + public editor: Editor; + + public element: HTMLElement; + + public view: EditorView; + + public preventHide = false; + + public tippy: Instance | undefined; + + public tippyOptions?: Partial; + + public renderContainerSelector?: string; + + public matchRenderContainer?: BubbleMenuPluginProps["matchRenderContainer"]; + + public shouldShow: Exclude = ({ + view, + state, + from, + to, + }) => { + const { doc, selection } = state; + const { empty } = selection; + + // Sometime check for `empty` is not enough. + // Doubleclick an empty paragraph returns a node size of 2. + // So we check also for an empty text size. + const isEmptyTextBlock = + !doc.textBetween(from, to).length && isTextSelection(state.selection); + + if (!view.hasFocus() || empty || isEmptyTextBlock) { + return false; + } + + return true; + }; + + constructor({ + editor, + element, + view, + tippyOptions = {}, + shouldShow, + renderContainerSelector, + matchRenderContainer, + }: BubbleMenuViewProps) { + this.editor = editor; + this.element = element; + this.view = view; + this.renderContainerSelector = renderContainerSelector; + this.matchRenderContainer = matchRenderContainer; + + if (shouldShow) { + this.shouldShow = shouldShow; + } + + this.element.addEventListener("mousedown", this.mousedownHandler, { + capture: true, + }); + this.view.dom.addEventListener("dragstart", this.dragstartHandler); + this.editor.on("focus", this.focusHandler); + this.editor.on("blur", this.blurHandler); + this.tippyOptions = tippyOptions; + // Detaches menu content from its current parent + this.element.remove(); + this.element.style.visibility = "visible"; + } + + mousedownHandler = () => { + this.preventHide = true; + }; + + dragstartHandler = () => { + this.hide(); + }; + + focusHandler = () => { + // we use `setTimeout` to make sure `selection` is already updated + setTimeout(() => this.update(this.editor.view)); + }; + + blurHandler = ({ event }: { event: FocusEvent }) => { + if (this.preventHide) { + this.preventHide = false; + + return; + } + + if ( + event?.relatedTarget && + this.element.parentNode?.contains(event.relatedTarget as Node) + ) { + return; + } + + this.hide(); + }; + + createTooltip() { + const { element: editorElement } = this.editor.options; + const editorIsAttached = !!editorElement.parentElement; + + if (this.tippy || !editorIsAttached) { + return; + } + + this.tippy = tippy(editorElement, { + duration: 0, + getReferenceClientRect: null, + content: this.element, + interactive: true, + trigger: "manual", + placement: "top", + hideOnClick: "toggle", + ...this.tippyOptions, + }); + + // maybe we have to hide tippy on its own blur event as well + if (this.tippy.popper.firstChild) { + (this.tippy.popper.firstChild as HTMLElement).addEventListener( + "blur", + (event) => { + this.blurHandler({ event }); + } + ); + } + } + + update(view: EditorView, oldState?: EditorState) { + const { state, composing } = view; + const { doc, selection } = state; + const isSame = + oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection); + + if (composing || isSame) { + return; + } + + this.createTooltip(); + + // support for CellSelections + const { ranges } = selection; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + + const shouldShow = this.shouldShow?.({ + editor: this.editor, + view, + state, + oldState, + from, + to, + }); + + if (!shouldShow) { + this.hide(); + + return; + } + + this.tippy?.setProps({ + getReferenceClientRect: () => { + if (isNodeSelection(state.selection)) { + let node = view.nodeDOM(from) as HTMLElement; + + if (this.matchRenderContainer) { + while (node && !this.matchRenderContainer(node)) { + node = node.firstElementChild as HTMLElement; + } + + if (node) { + return node.getBoundingClientRect(); + } + } + + if (node) { + return node.getBoundingClientRect(); + } + } + + if (this.matchRenderContainer) { + let node = view.domAtPos(from).node as HTMLElement; + + while (node && !this.matchRenderContainer(node)) { + node = node.parentElement; + } + + if (node) { + return node.getBoundingClientRect(); + } + } + + return posToDOMRect(view, from, to); + }, + }); + + this.show(); + } + + show() { + this.tippy?.show(); + } + + hide() { + this.tippy?.hide(); + } + + destroy() { + this.tippy?.destroy(); + this.element.removeEventListener("mousedown", this.mousedownHandler, { + capture: true, + }); + this.view.dom.removeEventListener("dragstart", this.dragstartHandler); + this.editor.off("focus", this.focusHandler); + this.editor.off("blur", this.blurHandler); + } +} + +export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) => { + return new Plugin({ + key: + typeof options.pluginKey === "string" + ? new PluginKey(options.pluginKey) + : options.pluginKey, + view: (view) => new BubbleMenuView({ view, ...options }), + }); +}; diff --git a/packages/client/src/components/tiptap/components/bubble-menu/index.tsx b/packages/client/src/components/tiptap/components/bubble-menu/index.tsx new file mode 100644 index 00000000..77a42a79 --- /dev/null +++ b/packages/client/src/components/tiptap/components/bubble-menu/index.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from "react"; +import { BubbleMenuPlugin, BubbleMenuPluginProps } from "./bubble-menu-plugin"; + +type Optional = Pick, K> & Omit; + +export type BubbleMenuProps = Omit< + Optional, + "element" +> & { + className?: string; +}; + +export const BubbleMenu: React.FC = (props) => { + const [element, setElement] = useState(null); + + useEffect(() => { + if (!element) { + return; + } + + if (props.editor.isDestroyed) { + return; + } + + const { + pluginKey = "bubbleMenu", + editor, + tippyOptions = {}, + shouldShow = null, + renderContainerSelector, + matchRenderContainer, + } = props; + + const plugin = BubbleMenuPlugin({ + pluginKey, + editor, + element, + tippyOptions, + shouldShow, + renderContainerSelector, + matchRenderContainer, + }); + + editor.registerPlugin(plugin); + return () => editor.unregisterPlugin(pluginKey); + }, [props.editor, element]); + + return ( +
+ {props.children} +
+ ); +}; diff --git a/packages/client/src/components/tiptap/components/color.tsx b/packages/client/src/components/tiptap/components/color.tsx new file mode 100644 index 00000000..aac7775a --- /dev/null +++ b/packages/client/src/components/tiptap/components/color.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Dropdown } from "@douyinfe/semi-ui"; +import styles from "./style.module.scss"; + +const colors = [ + "rgb(23, 43, 77)", + "rgb(7, 71, 166)", + "rgb(0, 141, 166)", + "rgb(0, 102, 68)", + "rgb(255, 153, 31)", + "rgb(191, 38, 0)", + "rgb(64, 50, 148)", + "rgb(151, 160, 175)", + "rgb(76, 154, 255)", + "rgb(0, 184, 217)", + "rgb(54, 179, 126)", + "rgb(255, 196, 0)", + "rgb(255, 86, 48)", + "rgb(101, 84, 192)", + "rgb(255, 255, 255)", + "rgb(179, 212, 255)", + "rgb(179, 245, 255)", + "rgb(171, 245, 209)", + "rgb(255, 240, 179)", + "rgb(255, 189, 173)", + "rgb(234, 230, 255)", +]; + +export const Color: React.FC<{ + onSetColor; + disabled?: boolean; +}> = ({ children, onSetColor, disabled = false }) => { + if (disabled) + return {children}; + + return ( + + {colors.map((color) => { + return ( +
onSetColor(color)} + > + +
+ ); + })} + + } + > + {children} +
+ ); +}; diff --git a/packages/client/src/components/tiptap/components/divider.tsx b/packages/client/src/components/tiptap/components/divider.tsx new file mode 100644 index 00000000..02913772 --- /dev/null +++ b/packages/client/src/components/tiptap/components/divider.tsx @@ -0,0 +1,13 @@ +export const Divider = () => { + return ( +
+ ); +}; diff --git a/packages/client/src/components/tiptap/components/style.module.scss b/packages/client/src/components/tiptap/components/style.module.scss new file mode 100644 index 00000000..09bb6f8f --- /dev/null +++ b/packages/client/src/components/tiptap/components/style.module.scss @@ -0,0 +1,35 @@ +.colorWrap { + display: flex; + flex-wrap: wrap; + width: 240px; + padding: 8px; + + .colorItem { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + cursor: pointer; + + &:nth-of-type(n + 8) { + margin-top: 4px; + } + + > span { + display: block; + width: 28px; + height: 28px; + border-radius: 2px; + border: 1px solid var(--semi-color-border); + } + } +} + +.visible { + display: block; +} + +.hidden { + display: none; +} diff --git a/packages/client/src/components/tiptap/extensions/attachment/index.module.scss b/packages/client/src/components/tiptap/extensions/attachment/index.module.scss new file mode 100644 index 00000000..cf5601f3 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/attachment/index.module.scss @@ -0,0 +1,9 @@ +.wrap { + display: flex; + justify-content: space-between; + align-items: center; + margin: 10px 0; + padding: 8px 16px; + border: 1px solid var(--semi-color-border); + border-radius: var(--border-radius); +} diff --git a/packages/client/src/components/tiptap/extensions/attachment/index.tsx b/packages/client/src/components/tiptap/extensions/attachment/index.tsx new file mode 100644 index 00000000..441df346 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/attachment/index.tsx @@ -0,0 +1,82 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { + NodeViewWrapper, + NodeViewContent, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { Button, Tooltip } from "@douyinfe/semi-ui"; +import { IconDownload } from "@douyinfe/semi-icons"; +import { download } from "../../utils/download"; +import styles from "./index.module.scss"; + +const Render = ({ node }) => { + const { name, url } = node.attrs; + + return ( + +
+ {name} + + +
+ +
+ ); +}; + +export const Attachment = Node.create({ + name: "attachment", + group: "block", + draggable: true, + + addOptions() { + return { + HTMLAttributes: { + class: "attachment", + }, + }; + }, + + parseHTML() { + return [{ tag: "div[class=attachment]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ]; + }, + + addAttributes() { + return { + name: { + default: null, + }, + url: { + default: null, + }, + }; + }, + // @ts-ignore + addCommands() { + return { + setAttachment: + (attrs) => + ({ chain }) => { + return chain().insertContent({ type: this.name, attrs }).run(); + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(Render); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/background-color.tsx b/packages/client/src/components/tiptap/extensions/background-color.tsx new file mode 100644 index 00000000..802c7388 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/background-color.tsx @@ -0,0 +1,73 @@ +import { Extension } from "@tiptap/core"; +import "@tiptap/extension-text-style"; + +export type ColorOptions = { + types: string[]; +}; + +declare module "@tiptap/core" { + interface Commands { + backgroundColor: { + /** + * Set the text color + */ + setBackgroundColor: (color: string) => ReturnType; + /** + * Unset the text color + */ + unsetBackgroundColor: () => ReturnType; + }; + } +} + +export const BackgroundColor = Extension.create({ + name: "backgroundColor", + + addOptions() { + return { + types: ["textStyle"], + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + backgroundColor: { + default: null, + parseHTML: (element) => + element.style.backgroundColor.replace(/['"]+/g, ""), + renderHTML: (attributes) => { + if (!attributes.backgroundColor) { + return {}; + } + + return { + style: `background-color: ${attributes.backgroundColor}`, + }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setBackgroundColor: + (color) => + ({ chain }) => { + return chain().setMark("textStyle", { backgroundColor: color }).run(); + }, + unsetBackgroundColor: + () => + ({ chain }) => { + return chain() + .setMark("textStyle", { backgroundColor: null }) + .removeEmptyTextStyle() + .run(); + }, + }; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/banner/index.module.scss b/packages/client/src/components/tiptap/extensions/banner/index.module.scss new file mode 100644 index 00000000..7a2c9875 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/banner/index.module.scss @@ -0,0 +1,25 @@ +.wrap { + margin: 12px 0; + line-height: 0; + border-radius: var(--border-radius); + border: 1px solid var(--semi-color-border); + margin: 16px 0; + + p { + margin-top: 0.25em; + } + + p:first-child { + margin: 0; + } + + ul { + margin: 0; + padding-left: 1em; + } + + .handlerWrap { + display: flex; + padding: 10px; + } +} diff --git a/packages/client/src/components/tiptap/extensions/banner/index.tsx b/packages/client/src/components/tiptap/extensions/banner/index.tsx new file mode 100644 index 00000000..81a26570 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/banner/index.tsx @@ -0,0 +1,86 @@ +import { Node, Command, mergeAttributes } from "@tiptap/core"; +import { + NodeViewWrapper, + NodeViewContent, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { Banner as SemiBanner } from "@douyinfe/semi-ui"; +import styles from "./index.module.scss"; + +declare module "@tiptap/core" { + interface Commands { + banner: { + setBanner: () => Command; + }; + } +} + +const BannerExtension = Node.create({ + name: "banner", + content: "block*", + group: "block", + defining: true, + draggable: true, + + addAttributes() { + return { + type: { + default: "info", + }, + }; + }, + + parseHTML() { + return [{ tag: "div" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + { class: "banner" }, + [ + "div", + mergeAttributes( + (this.options && this.options.HTMLAttributes) || {}, + HTMLAttributes + ), + 0, + ], + ]; + }, + + // @ts-ignore + addCommands() { + return { + setBanner: + (attributes) => + ({ commands, editor }) => { + const { type = null } = editor.getAttributes(this.name); + if (type) { + commands.lift(this.name); + } else { + return commands.toggleWrap(this.name, attributes); + } + }, + }; + }, +}); + +const Render = ({ node }) => { + return ( + + } + closeIcon={null} + fullMode={false} + /> + + ); +}; + +export const Banner = BannerExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(Render); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/code-block/index.module.scss b/packages/client/src/components/tiptap/extensions/code-block/index.module.scss new file mode 100644 index 00000000..ee2ce867 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/code-block/index.module.scss @@ -0,0 +1,14 @@ +.wrap { + position: relative; + + .handleWrap { + position: absolute; + right: 0.5rem; + top: 0.5rem; + z-index: 100; + + .selectorWrap { + margin-right: 8px; + } + } +} diff --git a/packages/client/src/components/tiptap/extensions/code-block/index.tsx b/packages/client/src/components/tiptap/extensions/code-block/index.tsx new file mode 100644 index 00000000..d8f3b395 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/code-block/index.tsx @@ -0,0 +1,65 @@ +import React, { useRef } from "react"; +import { + ReactNodeViewRenderer, + NodeViewWrapper, + NodeViewContent, +} from "@tiptap/react"; +import { Button, Select, Tooltip } from "@douyinfe/semi-ui"; +import { IconCopy } from "@douyinfe/semi-icons"; +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; +// @ts-ignore +import { lowlight } from "lowlight"; +import { copy } from "helpers/copy"; +import styles from "./index.module.scss"; + +const Render = ({ + editor, + node: { + attrs: { language: defaultLanguage }, + }, + updateAttributes, + extension, +}) => { + const isEditable = editor.isEditable; + const $container = useRef(); + + return ( + +
+ {isEditable && ( + + )} + +
+
+        
+      
+
+ ); +}; + +export const CodeBlock = CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(Render); + }, +}).configure({ lowlight }); diff --git a/packages/client/src/components/tiptap/extensions/color-highlight.tsx b/packages/client/src/components/tiptap/extensions/color-highlight.tsx new file mode 100644 index 00000000..da0e1a36 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/color-highlight.tsx @@ -0,0 +1,29 @@ +import { Extension } from "@tiptap/core"; +import { Plugin } from "prosemirror-state"; +import findColors from "../utils/find-colors"; + +export const ColorHighlighter = Extension.create({ + name: "colorHighlighter", + + addProseMirrorPlugins() { + return [ + new Plugin({ + state: { + init(_, { doc }) { + return findColors(doc); + }, + apply(transaction, oldState) { + return transaction.docChanged + ? findColors(transaction.doc) + : oldState; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }), + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/div.tsx b/packages/client/src/components/tiptap/extensions/div.tsx new file mode 100644 index 00000000..04339562 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/div.tsx @@ -0,0 +1,43 @@ +import { Node, mergeAttributes } from "@tiptap/core"; + +export const Div = Node.create({ + name: "div", + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + content: "block*", + group: "block", + defining: true, + parseHTML() { + return [{ tag: "div" }]; + }, + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + // @ts-ignore + addCommands() { + return { + setDiv: + (attributes) => + ({ commands }) => { + return commands.wrapIn("div", attributes); + }, + toggleDiv: + (attributes) => + ({ commands }) => { + return commands.toggleWrap("div", attributes); + }, + unsetDiv: + (attributes) => + ({ commands }) => { + return commands.lift("div"); + }, + }; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/documents/children/index.module.scss b/packages/client/src/components/tiptap/extensions/documents/children/index.module.scss new file mode 100644 index 00000000..5da0173f --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/documents/children/index.module.scss @@ -0,0 +1,23 @@ +.wrap { + margin: 28px 0 16px; + padding-top: 12px; + border-top: 1px solid var(--semi-color-border); + + .itemWrap { + display: flex; + align-items: center; + margin-top: 12px; + text-decoration: none; + color: var(--semi-color-text-1); + border-radius: var(--border-radius); + + &:hover { + color: var(--semi-color-link); + border-color: var(--semi-color-link); + } + + span { + margin-left: 8px; + } + } +} diff --git a/packages/client/src/components/tiptap/extensions/documents/children/index.tsx b/packages/client/src/components/tiptap/extensions/documents/children/index.tsx new file mode 100644 index 00000000..bcabc7bc --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/documents/children/index.tsx @@ -0,0 +1,153 @@ +import { + Node, + Command, + mergeAttributes, + textInputRule, + textblockTypeInputRule, + wrappingInputRule, +} from "@tiptap/core"; +import { + NodeViewWrapper, + NodeViewContent, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { useRouter } from "next/router"; +import Link from "next/link"; +import { Space, Popover, Tag, Input, Typography } from "@douyinfe/semi-ui"; +import { useChildrenDocument } from "data/document"; +import { DataRender } from "components/data-render"; +import { Empty } from "components/empty"; +import { IconDocument } from "components/icons"; +import styles from "./index.module.scss"; + +const { Text } = Typography; + +declare module "@tiptap/core" { + interface Commands { + documentChildren: { + setDocumentChildren: () => Command; + }; + } +} + +export const DocumentChildrenInputRegex = /^documentChildren\$$/; + +const DocumentChildrenExtension = Node.create({ + name: "documentChildren", + group: "block", + defining: true, + draggable: true, + + addAttributes() { + return { + color: { + default: "grey", + }, + text: { + default: "", + }, + }; + }, + + parseHTML() { + return [{ tag: "div[data-type=documentChildren]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + (this.options && this.options.HTMLAttributes) || {}, + HTMLAttributes + ), + ]; + }, + + // @ts-ignore + addCommands() { + return { + setDocumentChildren: + () => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: {}, + }); + }, + }; + }, + + addInputRules() { + return [ + wrappingInputRule({ + find: DocumentChildrenInputRegex, + type: this.type, + }), + ]; + }, +}); + +const Render = () => { + const { pathname, query } = useRouter(); + const wikiId = query?.wikiId; + const documentId = query?.documentId; + const isShare = pathname.includes("share"); + const { + data: documents, + loading, + error, + } = useChildrenDocument({ wikiId, documentId, isShare }); + + return ( + +
+
+ 子文档 +
+ {wikiId || documentId ? ( + { + if (!documents || !documents.length) { + return ; + } + return ( +
+ {documents.map((doc) => { + return ( + + + + {doc.title} + + + ); + })} +
+ ); + }} + /> + ) : ( + 当前页面无法使用子文档 + )} +
+ + +
+ ); +}; + +export const DocumentChildren = DocumentChildrenExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(Render); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/documents/reference/index.module.scss b/packages/client/src/components/tiptap/extensions/documents/reference/index.module.scss new file mode 100644 index 00000000..698efe10 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/documents/reference/index.module.scss @@ -0,0 +1,23 @@ +.wrap { + margin: 8px 0; + + .itemWrap { + display: flex; + align-items: center; + padding: 8px; + border: 1px solid var(--semi-color-border); + margin-top: 12px; + border-radius: var(--border-radius); + text-decoration: none; + color: var(--semi-color-text-1); + + &:hover { + color: var(--semi-color-link); + border-color: var(--semi-color-link); + } + + span { + margin-left: 8px; + } + } +} diff --git a/packages/client/src/components/tiptap/extensions/documents/reference/index.tsx b/packages/client/src/components/tiptap/extensions/documents/reference/index.tsx new file mode 100644 index 00000000..962ac000 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/documents/reference/index.tsx @@ -0,0 +1,199 @@ +import { + Node, + Command, + mergeAttributes, + textInputRule, + textblockTypeInputRule, + wrappingInputRule, +} from "@tiptap/core"; +import { + NodeViewWrapper, + NodeViewContent, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { useRouter } from "next/router"; +import Link from "next/link"; +import { + Space, + Select, + Popover, + Tag, + Input, + Typography, +} from "@douyinfe/semi-ui"; +import { useWikiTocs } from "data/wiki"; +import { useDocumentDetail } from "data/document"; +import { DataRender } from "components/data-render"; +import { Empty } from "components/empty"; +import { IconDocument } from "components/icons"; +import styles from "./index.module.scss"; + +const { Text } = Typography; + +declare module "@tiptap/core" { + interface Commands { + documentReference: { + setDocumentReference: () => Command; + }; + } +} + +export const DocumentReferenceInputRegex = /^documentReference\$$/; + +const DocumentReferenceExtension = Node.create({ + name: "documentReference", + group: "block", + defining: true, + draggable: true, + + addAttributes() { + return { + wikiId: { + default: "", + }, + documentId: { + default: "", + }, + title: { + default: "", + }, + }; + }, + + parseHTML() { + return [{ tag: "div[data-type=documentReference]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + (this.options && this.options.HTMLAttributes) || {}, + HTMLAttributes + ), + ]; + }, + + // @ts-ignore + addCommands() { + return { + setDocumentReference: + () => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: {}, + }); + }, + }; + }, + + addInputRules() { + return [ + wrappingInputRule({ + find: DocumentReferenceInputRegex, + type: this.type, + }), + ]; + }, +}); + +const Render = ({ editor, node, updateAttributes }) => { + const { pathname, query } = useRouter(); + const wikiIdFromUrl = query?.wikiId; + const isShare = pathname.includes("share"); + const isEditable = editor.isEditable; + const { wikiId, documentId, title } = node.attrs; + const { + data: tocs, + loading, + error, + } = useWikiTocs(isShare ? null : wikiIdFromUrl); + + return ( + +
+ {isEditable && ( + + )} + + + + + {title} + + +
+ {/*
+ 子文档 + { + if (!documents || !documents.length) { + return ; + } + return ( +
+ {documents.map((doc) => { + return ( + + + + {doc.title} + + + ); + })} +
+ ); + }} + /> +
*/} + +
+ ); +}; + +export const DocumentReference = DocumentReferenceExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(Render); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/font-size.tsx b/packages/client/src/components/tiptap/extensions/font-size.tsx new file mode 100644 index 00000000..754e12a8 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/font-size.tsx @@ -0,0 +1,67 @@ +import { Extension } from "@tiptap/core"; +import "@tiptap/extension-text-style"; + +type FontSizeOptions = { + types: string[]; +}; + +declare module "@tiptap/core" { + interface Commands { + fontSize: { + setFontSize: (size: string) => ReturnType; + unsetFontSize: () => ReturnType; + }; + } +} + +export const FontSize = Extension.create({ + name: "fontSize", + + addOptions() { + return { + types: ["textStyle"], + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + fontSize: { + default: null, + parseHTML: (element) => + element.style.fontSize.replace(/['"]+/g, ""), + renderHTML: (attributes) => { + if (!attributes.fontSize) { + return {}; + } + + return { + style: `font-size: ${attributes.fontSize}`, + }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setFontSize: + (fontSize) => + ({ chain }) => { + return chain().setMark("textStyle", { fontSize }).run(); + }, + unsetFontSize: + () => + ({ chain }) => { + return chain() + .setMark("textStyle", { fontSize: null }) + .removeEmptyTextStyle() + .run(); + }, + }; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/horizontal-rule.tsx b/packages/client/src/components/tiptap/extensions/horizontal-rule.tsx new file mode 100644 index 00000000..e91d7a72 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/horizontal-rule.tsx @@ -0,0 +1,88 @@ +import { Node, nodeInputRule, mergeAttributes } from "@tiptap/core"; +import { TextSelection } from "prosemirror-state"; + +export interface HorizontalRuleOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + horizontalRule: { + /** + * Add a horizontal rule + */ + setHorizontalRule: () => ReturnType; + }; + } +} + +export const HorizontalRule = Node.create({ + name: "horizontalRule", + group: "block", + + addOptions() { + return { + HTMLAttributes: { + class: "hr-line", + }, + }; + }, + + parseHTML() { + return [{ tag: "div[class=hr-line]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ]; + }, + + addCommands() { + return { + setHorizontalRule: + () => + ({ chain }) => { + return ( + chain() + .insertContent({ type: this.name }) + // set cursor after horizontal rule + .command(({ tr, dispatch }) => { + if (dispatch) { + const { $to } = tr.selection; + const posAfter = $to.end(); + + if ($to.nodeAfter) { + tr.setSelection(TextSelection.create(tr.doc, $to.pos)); + } else { + // add node after horizontal rule if it’s the end of the document + const node = + $to.parent.type.contentMatch.defaultType?.create(); + + if (node) { + tr.insert(posAfter, node); + tr.setSelection(TextSelection.create(tr.doc, posAfter)); + } + } + + tr.scrollIntoView(); + } + + return true; + }) + .run() + ); + }, + }; + }, + + addInputRules() { + return [ + nodeInputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + type: this.type, + }), + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/iframe/index.module.scss b/packages/client/src/components/tiptap/extensions/iframe/index.module.scss new file mode 100644 index 00000000..25c60abf --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/iframe/index.module.scss @@ -0,0 +1,35 @@ +.wrap { + max-width: 100%; + height: 100%; + line-height: 0; + border-radius: var(--border-radius); + border: 1px solid var(--semi-color-border); + background-color: var(--semi-color-fill-0); + overflow: visible; + + display: flex; + flex-direction: column; + overflow: hidden; + + .handlerWrap { + display: flex; + padding: 10px; + } + + .innerWrap { + flex: 1; + position: relative; + width: 100%; + // height: 100%; + border-radius: var(--border-radius); + overflow: hidden; + } + + :global { + iframe { + width: 100%; + height: 100%; + border: 0; + } + } +} diff --git a/packages/client/src/components/tiptap/extensions/iframe/index.tsx b/packages/client/src/components/tiptap/extensions/iframe/index.tsx new file mode 100644 index 00000000..f5e52264 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/iframe/index.tsx @@ -0,0 +1,132 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { + NodeViewWrapper, + NodeViewContent, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { Input } from "@douyinfe/semi-ui"; +import { Resizeable } from "components/resizeable"; +import styles from "./index.module.scss"; + +const IframeNode = Node.create({ + name: "external-iframe", + content: "", + marks: "", + group: "block", + draggable: true, + + addOptions() { + return { + HTMLAttributes: { + "data-type": "external-iframe", + }, + }; + }, + + addAttributes() { + return { + width: { + default: "100%", + }, + height: { + default: 54, + }, + url: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'iframe[data-type="external-iframe"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "iframe", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ]; + }, + + // @ts-ignore + addCommands() { + return { + insertIframe: + (options) => + ({ tr, commands, chain, editor }) => { + if (tr.selection?.node?.type?.name == this.name) { + return commands.updateAttributes(this.name, options); + } + + const { url } = options || {}; + const { selection } = editor.state; + const pos = selection.$head; + + return chain() + .insertContentAt(pos.before(), [ + { + type: this.name, + attrs: { url }, + }, + ]) + .run(); + }, + }; + }, +}); + +const Render = ({ editor, node, updateAttributes }) => { + const isEditable = editor.isEditable; + const { url, width, height } = node.attrs; + + const onResize = (size) => { + updateAttributes({ width: size.width, height: size.height }); + }; + const content = ( + + {isEditable && ( +
+ updateAttributes({ url })} + > +
+ )} + {url && ( +
+ +
+ )} +
+ ); + + if (!isEditable && !url) { + return null; + } + + return ( + + {isEditable ? ( + + {content} + + ) : ( +
{content}
+ )} +
+ ); +}; + +export const Iframe = IframeNode.extend({ + addNodeView() { + return ReactNodeViewRenderer(Render); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/image.tsx b/packages/client/src/components/tiptap/extensions/image.tsx new file mode 100644 index 00000000..95ad23fa --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/image.tsx @@ -0,0 +1,67 @@ +import { Plugin } from "prosemirror-state"; +import { Image as TImage } from "@tiptap/extension-image"; +import { + NodeViewWrapper, + NodeViewContent, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { Resizeable } from "components/resizeable"; +import { uploadFile } from "services/file"; + +const Render = ({ editor, node, updateAttributes }) => { + const isEditable = editor.isEditable; + const { src, alt, title, width, height, textAlign } = node.attrs; + + const onResize = (size) => { + updateAttributes({ height: size.height, width: size.width }); + }; + + const content = src && ( + {title} + ); + + return ( + + {isEditable ? ( + + {content} + + ) : ( +
+ {content} +
+ )} +
+ ); +}; + +export const Image = TImage.extend({ + draggable: true, + addAttributes() { + return { + src: { + default: null, + }, + alt: { + default: null, + }, + title: { + default: null, + }, + width: { + default: "auto", + }, + height: { + default: "auto", + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(Render); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/indent.tsx b/packages/client/src/components/tiptap/extensions/indent.tsx new file mode 100644 index 00000000..68674c03 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/indent.tsx @@ -0,0 +1,175 @@ +import { Command, Extension } from "@tiptap/core"; +import { sinkListItem, liftListItem } from "prosemirror-schema-list"; +import { TextSelection, AllSelection, Transaction } from "prosemirror-state"; +import { clamp } from "../utils/shared"; +import { isListActive } from "../utils/active"; +import { getNodeType } from "../utils/type"; +import { isListNode } from "../utils/node"; + +type IndentOptions = { + types: string[]; + indentLevels: number[]; + defaultIndentLevel: number; +}; + +declare module "@tiptap/core" { + interface Commands { + indent: { + indent: () => Command; + outdent: () => Command; + }; + } +} + +export enum IndentProps { + min = 0, + max = 210, + more = 30, + less = -30, +} + +function setNodeIndentMarkup( + tr: Transaction, + pos: number, + delta: number +): Transaction { + if (!tr.doc) return tr; + + const node = tr.doc.nodeAt(pos); + if (!node) return tr; + + const minIndent = IndentProps.min; + const maxIndent = IndentProps.max; + + const indent = clamp((node.attrs.indent || 0) + delta, minIndent, maxIndent); + + if (indent === node.attrs.indent) return tr; + + const nodeAttrs = { + ...node.attrs, + indent, + }; + + return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks); +} + +function updateIndentLevel(tr: Transaction, delta: number): Transaction { + const { doc, selection } = tr; + + if (!doc || !selection) return tr; + + if ( + !(selection instanceof TextSelection || selection instanceof AllSelection) + ) { + return tr; + } + + const { from, to } = selection; + + doc.nodesBetween(from, to, (node, pos) => { + const nodeType = node.type; + + if (nodeType.name === "paragraph" || nodeType.name === "heading") { + tr = setNodeIndentMarkup(tr, pos, delta); + return false; + } + if (isListNode(node)) { + return false; + } + return true; + }); + + return tr; +} + +export const Indent = Extension.create({ + name: "indent", + + addOptions() { + return { + types: ["heading", "paragraph"], + indentLevels: [0, 30, 60, 90, 120, 150, 180, 210], + defaultIndentLevel: 0, + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + indent: { + default: this.options.defaultIndentLevel, + renderHTML: (attributes) => ({ + style: `margin-left: ${attributes.indent}px!important;`, + }), + parseHTML: (element) => + parseInt(element.style.marginLeft) || + this.options.defaultIndentLevel, + }, + }, + }, + ]; + }, + + addCommands() { + return { + indent: + () => + ({ tr, state, dispatch }) => { + if (isListActive(this.editor)) { + const name = this.editor.can().liftListItem("taskItem") + ? "taskItem" + : "listItem"; + const type = getNodeType(name, state.schema); + return sinkListItem(type)(state, dispatch); + } + + const { selection } = state; + tr = tr.setSelection(selection); + tr = updateIndentLevel(tr, IndentProps.more); + + if (tr.docChanged) { + dispatch && dispatch(tr); + return true; + } + + return false; + }, + outdent: + () => + ({ tr, state, dispatch }) => { + if (isListActive(this.editor)) { + const name = this.editor.can().liftListItem("taskItem") + ? "taskItem" + : "listItem"; + const type = getNodeType(name, state.schema); + return liftListItem(type)(state, dispatch); + } + + const { selection } = state; + tr = tr.setSelection(selection); + tr = updateIndentLevel(tr, IndentProps.less); + + if (tr.docChanged) { + dispatch && dispatch(tr); + return true; + } + + return false; + }, + }; + }, + + // @ts-ignore + addKeyboardShortcuts() { + return { + Tab: () => { + return this.editor.commands.indent(); + }, + "Shift-Tab": () => { + return this.editor.commands.outdent(); + }, + }; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/katex/index.module.scss b/packages/client/src/components/tiptap/extensions/katex/index.module.scss new file mode 100644 index 00000000..9796867f --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/katex/index.module.scss @@ -0,0 +1,5 @@ +.wrap { + margin: 8px 0; + display: flex; + justify-content: center; +} diff --git a/packages/client/src/components/tiptap/extensions/katex/index.tsx b/packages/client/src/components/tiptap/extensions/katex/index.tsx new file mode 100644 index 00000000..2dd4834f --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/katex/index.tsx @@ -0,0 +1,148 @@ +import { + Node, + Command, + mergeAttributes, + wrappingInputRule, +} from "@tiptap/core"; +import { + NodeViewWrapper, + NodeViewContent, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { Popover, TextArea, Typography, Space } from "@douyinfe/semi-ui"; +import { IconHelpCircle } from "@douyinfe/semi-icons"; +import katex from "katex"; +import styles from "./index.module.scss"; +import { useMemo } from "react"; + +declare module "@tiptap/core" { + interface Commands { + katex: { + setKatex: () => Command; + }; + } +} + +const { Text } = Typography; + +export const KatexInputRegex = /^\$\$(.+)?\$$/; + +const KatexExtension = Node.create({ + name: "katex", + group: "block", + defining: true, + draggable: true, + + addAttributes() { + return { + text: { + default: "", + }, + }; + }, + + parseHTML() { + return [{ tag: "div[data-type=katex]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + (this.options && this.options.HTMLAttributes) || {}, + HTMLAttributes + ), + ]; + }, + + // @ts-ignore + addCommands() { + return { + setKatex: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }); + }, + }; + }, + + addInputRules() { + return [ + wrappingInputRule({ + find: KatexInputRegex, + type: this.type, + getAttributes: (match) => { + return { text: match[1] }; + }, + }), + ]; + }, +}); + +const Render = ({ editor, node, updateAttributes }) => { + const isEditable = editor.isEditable; + const { text } = node.attrs; + const formatText = useMemo(() => { + try { + return katex.renderToString(`${text}`); + } catch (e) { + return text; + } + }, [text]); + + const content = text ? ( + + ) : ( + 请输入公式 + ); + + return ( + + {isEditable ? ( + +