mirror of
https://github.com/makeplane/plane.git
synced 2025-12-16 11:57:56 +01:00
[WEB-1116] feat: pages realtime collaboration (#5493)
* [WEB-1116] feat: pages realtime sync (#5057) * init: live server for editor realtime sync * chore: authentication added * chore: updated logic to convert html to binary for old pages * chore: added description json on page update * chore: made all functions generic * chore: save description in json and html formats * refactor: document editor components * chore: uncomment ui package components * fix: without props extensions refactor * fix: merge conflicts resolved from preview * chore: init docker compose * chore: pages custom error codes * chore: add health check endpoint to the live server * chore: update without props extensions type * chore: better error handling * chore: update react-hook-form versions --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com> * fix: docker related fixes * fix: module type fixes * fix: nginx update * fix: adding live server workflow * fix: workflow fixes * fix: docker compose fixes * fix: workflow fixes * fix: path config * fix: docker compose warnings * fix: nginx port forwarding * fix: update docker compose with new env * fix: env var fixes * fix: error handling * fix: docker compose env var * fix: compose fixes * chore: update server start message * chore: handle errors * fix: build errors * chore: update port * chore: update server port * chore: show error on authentication fail * chore: show error on authentication fail * feat: add redis extension * chore: updated restore version logic --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com> Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
This commit is contained in:
committed by
GitHub
parent
2c950713a7
commit
6c3a8a9647
60
.github/workflows/build-branch.yml
vendored
60
.github/workflows/build-branch.yml
vendored
@@ -27,6 +27,7 @@ jobs:
|
||||
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
|
||||
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
|
||||
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
|
||||
build_live: ${{ steps.changed_files.outputs.live_any_changed }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
@@ -79,6 +80,13 @@ jobs:
|
||||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
live:
|
||||
- live/**
|
||||
- packages/**
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
|
||||
branch_build_push_web:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
@@ -288,6 +296,58 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
branch_build_push_live:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
LIVE_TAG: makeplane/plane-live:${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
|
||||
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Set Live Docker Tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
TAG=makeplane/plane-live:stable,makeplane/plane-live:${{ github.event.release.tag_name }}
|
||||
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
|
||||
TAG=makeplane/plane-live:latest
|
||||
else
|
||||
TAG=${{ env.LIVE_TAG }}
|
||||
fi
|
||||
echo "LIVE_TAG=${TAG}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push Live Server to Docker Hub
|
||||
uses: docker/build-push-action@v5.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./live/Dockerfile.live
|
||||
platforms: ${{ env.BUILDX_PLATFORMS }}
|
||||
tags: ${{ env.LIVE_TAG }}
|
||||
push: true
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
branch_build_push_proxy:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.51.0",
|
||||
"react-hook-form": "7.51.5",
|
||||
"swr": "^2.2.4",
|
||||
"tailwindcss": "3.3.2",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -47,4 +47,4 @@
|
||||
"tsconfig": "*",
|
||||
"typescript": "^5.4.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +566,7 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
# Store the updated binary data
|
||||
page.description_binary = new_binary_data
|
||||
page.description_html = request.data.get("description_html")
|
||||
page.description = request.data.get("description")
|
||||
page.save()
|
||||
# Return a success response
|
||||
page_version.delay(
|
||||
|
||||
@@ -3,9 +3,9 @@ x-app-env: &app-env
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- WEB_URL=${WEB_URL:-http://localhost}
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
|
||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
|
||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
|
||||
# Gunicorn Workers
|
||||
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-1}
|
||||
#DB SETTINGS
|
||||
@@ -43,10 +43,8 @@ x-app-env: &app-env
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
|
||||
- BUCKET_NAME=${BUCKET_NAME:-uploads}
|
||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||
# Admin and Space URLs
|
||||
- ADMIN_BASE_URL=${ADMIN_BASE_URL}
|
||||
- SPACE_BASE_URL=${SPACE_BASE_URL}
|
||||
- APP_BASE_URL=${APP_BASE_URL}
|
||||
# Live server env
|
||||
- API_BASE_URL=${API_BASE_URL:-http://api:8000}
|
||||
|
||||
services:
|
||||
web:
|
||||
@@ -89,6 +87,19 @@ services:
|
||||
- api
|
||||
- web
|
||||
|
||||
live:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node live/dist/server.js live
|
||||
deploy:
|
||||
replicas: ${LIVE_REPLICAS:-1}
|
||||
depends_on:
|
||||
- api
|
||||
- web
|
||||
|
||||
api:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
|
||||
@@ -91,6 +91,20 @@ services:
|
||||
- worker
|
||||
- web
|
||||
|
||||
live:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./live/Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev_env
|
||||
volumes:
|
||||
- ./live:/app/live
|
||||
depends_on:
|
||||
- api
|
||||
- worker
|
||||
- web
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./apiserver
|
||||
|
||||
@@ -99,6 +99,16 @@ services:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
live:
|
||||
container_name: plane-live
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./live/Dockerfile.live
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
restart: always
|
||||
command: node live/dist/server.js
|
||||
|
||||
plane-db:
|
||||
container_name: plane-db
|
||||
image: postgres:15.7-alpine
|
||||
|
||||
1
live/.env.example
Normal file
1
live/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
API_BASE_URL="http://api:8000"
|
||||
13
live/Dockerfile.dev
Normal file
13
live/Dockerfile.dev
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
EXPOSE 3003
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/live/node_modules"]
|
||||
CMD ["yarn","dev", "--filter=live"]
|
||||
44
live/Dockerfile.live
Normal file
44
live/Dockerfile.live
Normal file
@@ -0,0 +1,44 @@
|
||||
FROM node:18-alpine AS base
|
||||
# The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
|
||||
# Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs.
|
||||
|
||||
FROM base AS builder
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
RUN turbo prune live --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM base AS installer
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# First install dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
|
||||
# Build the project and its dependencies
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
RUN yarn turbo build --filter=live
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 expressjs
|
||||
RUN adduser --system --uid 1001 expressjs
|
||||
USER expressjs
|
||||
|
||||
COPY --from=installer /app .
|
||||
# COPY --from=installer /app/live/node_modules ./node_modules
|
||||
|
||||
EXPOSE 3000
|
||||
44
live/package.json
Normal file
44
live/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.22.0",
|
||||
"description": "",
|
||||
"main": "./src/server.ts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "PORT=3003 nodemon --exec \"node -r esbuild-register ./src/server.ts\" -e .ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "^2.11.3",
|
||||
"@hocuspocus/extension-logger": "^2.11.3",
|
||||
"@hocuspocus/extension-redis": "^2.13.5",
|
||||
"@hocuspocus/server": "^2.11.3",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@tiptap/core": "^2.4.0",
|
||||
"@tiptap/html": "^2.3.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-ws": "^5.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"y-prosemirror": "^1.2.9",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dotenv": "^8.2.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-ws": "^3.0.4",
|
||||
"@types/node": "^20.14.9",
|
||||
"tsup": "^7.2.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
2
live/src/ce/types/common.d.ts
vendored
Normal file
2
live/src/ce/types/common.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type TAdditionalDocumentTypes = {}
|
||||
62
live/src/core/lib/authentication.ts
Normal file
62
live/src/core/lib/authentication.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ConnectionConfiguration } from "@hocuspocus/server";
|
||||
// services
|
||||
import { UserService } from "../services/user.service.js";
|
||||
// types
|
||||
import { TDocumentTypes } from "../types/common.js";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
type Props = {
|
||||
connection: ConnectionConfiguration;
|
||||
cookie: string;
|
||||
params: URLSearchParams;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const handleAuthentication = async (props: Props) => {
|
||||
const { connection, cookie, params, token } = props;
|
||||
// params
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
const documentType = params.get("documentType")?.toString() as
|
||||
| TDocumentTypes
|
||||
| undefined;
|
||||
// fetch current user info
|
||||
let response;
|
||||
try {
|
||||
response = await userService.currentUser(cookie);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch current user:", error);
|
||||
throw error;
|
||||
}
|
||||
if (response.id !== token) {
|
||||
throw Error("Authentication failed: Token doesn't match the current user.");
|
||||
}
|
||||
|
||||
if (documentType === "project_page") {
|
||||
if (!workspaceSlug || !projectId) {
|
||||
throw Error(
|
||||
"Authentication failed: Incomplete query params. Either workspaceSlug or projectId is missing."
|
||||
);
|
||||
}
|
||||
// fetch current user's roles
|
||||
const workspaceRoles = await userService.getUserAllProjectsRole(
|
||||
workspaceSlug,
|
||||
cookie
|
||||
);
|
||||
const currentProjectRole = workspaceRoles[projectId];
|
||||
// make the connection read only for roles lower than a member
|
||||
if (currentProjectRole < 15) {
|
||||
connection.readOnly = true;
|
||||
}
|
||||
} else {
|
||||
throw Error("Authentication failed: Invalid document type provided.");
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: response.id,
|
||||
name: response.display_name,
|
||||
},
|
||||
};
|
||||
};
|
||||
144
live/src/core/lib/page.ts
Normal file
144
live/src/core/lib/page.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
prosemirrorJSONToYDoc,
|
||||
yXmlFragmentToProseMirrorRootNode,
|
||||
} from "y-prosemirror";
|
||||
// editor
|
||||
import {
|
||||
CoreEditorExtensionsWithoutProps,
|
||||
DocumentEditorExtensionsWithoutProps,
|
||||
} from "@plane/editor/lib";
|
||||
// services
|
||||
import { PageService } from "../services/page.service.js";
|
||||
const pageService = new PageService();
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [
|
||||
...CoreEditorExtensionsWithoutProps,
|
||||
...DocumentEditorExtensionsWithoutProps,
|
||||
];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
export const updatePageDescription = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
updatedDescription: Uint8Array,
|
||||
cookie: string | undefined
|
||||
) => {
|
||||
if (!(updatedDescription instanceof Uint8Array)) {
|
||||
throw new Error(
|
||||
"Invalid updatedDescription: must be an instance of Uint8Array"
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
// encode binary description data
|
||||
const base64Data = Buffer.from(updatedDescription).toString("base64");
|
||||
const yDoc = new Y.Doc();
|
||||
Y.applyUpdate(yDoc, updatedDescription);
|
||||
// convert to JSON
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(
|
||||
type,
|
||||
documentEditorSchema
|
||||
).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
description_binary: base64Data,
|
||||
description_html: contentHTML,
|
||||
description: contentJSON,
|
||||
};
|
||||
|
||||
await pageService.updateDescription(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
payload,
|
||||
cookie
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Update error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDescriptionHTMLAndTransform = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string
|
||||
) => {
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
|
||||
try {
|
||||
const pageDetails = await pageService.fetchDetails(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie
|
||||
);
|
||||
// convert already existing html to json
|
||||
const contentJSON = generateJSON(
|
||||
pageDetails.description_html ?? "<p></p>",
|
||||
DOCUMENT_EDITOR_EXTENSIONS
|
||||
);
|
||||
// get editor schema from the DOCUMENT_EDITOR_EXTENSIONS array
|
||||
const schema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert json to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(
|
||||
schema,
|
||||
contentJSON,
|
||||
"default"
|
||||
);
|
||||
// convert Y.Doc to Uint8Array format
|
||||
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
||||
|
||||
return encodedData;
|
||||
} catch (error) {
|
||||
console.error("Error while transforming from HTML to Uint8Array", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPageDescriptionBinary = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
cookie: string | undefined
|
||||
) => {
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
if (!workspaceSlug || !projectId || !cookie) return null;
|
||||
|
||||
try {
|
||||
const response = await pageService.fetchDescriptionBinary(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie
|
||||
);
|
||||
const binaryData = new Uint8Array(response);
|
||||
|
||||
if (binaryData.byteLength === 0) {
|
||||
const binary = await fetchDescriptionHTMLAndTransform(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie
|
||||
);
|
||||
if (binary) {
|
||||
return binary;
|
||||
}
|
||||
}
|
||||
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
console.error("Fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
46
live/src/core/services/api.service.ts
Normal file
46
live/src/core/services/api.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config();
|
||||
|
||||
export const API_BASE_URL = process.env.API_BASE_URL ?? "";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
get(url: string, params = {}, config = {}) {
|
||||
return this.axiosInstance.get(url, {
|
||||
...params,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
post(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.post(url, data, config);
|
||||
}
|
||||
|
||||
put(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.put(url, data, config);
|
||||
}
|
||||
|
||||
patch(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.patch(url, data, config);
|
||||
}
|
||||
|
||||
delete(url: string, data?: any, config = {}) {
|
||||
return this.axiosInstance.delete(url, { data, ...config });
|
||||
}
|
||||
|
||||
request(config = {}) {
|
||||
return this.axiosInstance(config);
|
||||
}
|
||||
}
|
||||
78
live/src/core/services/page.service.ts
Normal file
78
live/src/core/services/page.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "./api.service.js";
|
||||
|
||||
export class PageService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async fetchDetails(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string
|
||||
): Promise<TPage> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDescriptionBinary(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string
|
||||
): Promise<any> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Cookie: cookie,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateDescription(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
data: {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
description: object;
|
||||
},
|
||||
cookie: string
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
46
live/src/core/services/user.service.ts
Normal file
46
live/src/core/services/user.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// types
|
||||
import type { IUser, IUserProjectsRole } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "./api.service.js";
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
currentUserConfig() {
|
||||
return {
|
||||
url: `${this.baseURL}/api/users/me/`,
|
||||
};
|
||||
}
|
||||
|
||||
async currentUser(cookie: string): Promise<IUser> {
|
||||
return this.get("/api/users/me/", {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async getUserAllProjectsRole(
|
||||
workspaceSlug: string,
|
||||
cookie: string
|
||||
): Promise<IUserProjectsRole> {
|
||||
return this.get(
|
||||
`/api/users/me/workspaces/${workspaceSlug}/project-roles/`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
3
live/src/core/types/common.d.ts
vendored
Normal file
3
live/src/core/types/common.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
|
||||
|
||||
export type TDocumentTypes = "project_page" & TAdditionalDocumentTypes;
|
||||
1
live/src/ee/types/common.d.ts
vendored
Normal file
1
live/src/ee/types/common.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../ce/types/common.js"
|
||||
123
live/src/server.ts
Normal file
123
live/src/server.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import { Redis } from "@hocuspocus/extension-redis";
|
||||
|
||||
import { Database } from "@hocuspocus/extension-database";
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import express from "express";
|
||||
import expressWs, { Application } from "express-ws";
|
||||
// page actions
|
||||
import {
|
||||
fetchPageDescriptionBinary,
|
||||
updatePageDescription,
|
||||
} from "./core/lib/page.js";
|
||||
// types
|
||||
import { TDocumentTypes } from "./core/types/common.js";
|
||||
// helpers
|
||||
import { handleAuthentication } from "./core/lib/authentication.js";
|
||||
|
||||
const server = Server.configure({
|
||||
onAuthenticate: async ({
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
connection,
|
||||
// user id used as token for authentication
|
||||
token,
|
||||
}) => {
|
||||
// request headers
|
||||
const cookie = requestHeaders.cookie?.toString();
|
||||
// params
|
||||
const params = requestParameters;
|
||||
|
||||
if (!cookie) {
|
||||
throw Error("Credentials not provided");
|
||||
}
|
||||
|
||||
try {
|
||||
await handleAuthentication({
|
||||
connection,
|
||||
cookie,
|
||||
params,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
},
|
||||
extensions: [
|
||||
new Redis({
|
||||
host: process.env.REDIS_HOST || "localhost",
|
||||
port: Number(process.env.REDIS_PORT || 6379),
|
||||
}),
|
||||
new Logger(),
|
||||
new Database({
|
||||
fetch: async ({
|
||||
documentName: pageId,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
}) => {
|
||||
// request headers
|
||||
const cookie = requestHeaders.cookie?.toString();
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as
|
||||
| TDocumentTypes
|
||||
| undefined;
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
if (documentType === "project_page") {
|
||||
const fetchedData = await fetchPageDescriptionBinary(
|
||||
params,
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
resolve(fetchedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in fetching document", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
store: async ({
|
||||
state,
|
||||
documentName: pageId,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
}) => {
|
||||
// request headers
|
||||
const cookie = requestHeaders.cookie?.toString();
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as
|
||||
| TDocumentTypes
|
||||
| undefined;
|
||||
|
||||
return new Promise(async () => {
|
||||
try {
|
||||
if (documentType === "project_page") {
|
||||
await updatePageDescription(params, pageId, state, cookie);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in updating document", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { app }: { app: Application } = expressWs(express());
|
||||
|
||||
app.set("port", process.env.PORT || 3000);
|
||||
|
||||
app.get("/health", (_request, response) => {
|
||||
response.status(200);
|
||||
});
|
||||
|
||||
app.ws("/collaboration", (websocket, request) => {
|
||||
server.handleConnection(websocket, request);
|
||||
});
|
||||
|
||||
app.listen(app.get("port"), () => {
|
||||
console.log("Live server has started at port", app.get("port"));
|
||||
});
|
||||
20
live/tsconfig.json
Normal file
20
live/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2015"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/core/*"],
|
||||
"@/plane-live/*": ["./src/ce/*"]
|
||||
},
|
||||
"removeComments": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["./dist", "./build", "./node_modules"]
|
||||
}
|
||||
11
live/tsup.config.ts
Normal file
11
live/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/server.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
||||
@@ -28,14 +28,6 @@ http {
|
||||
proxy_pass http://web:3000/;
|
||||
}
|
||||
|
||||
location /god-mode/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://admin:3000/god-mode/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
@@ -52,6 +44,22 @@ http {
|
||||
proxy_pass http://api:8000/auth/;
|
||||
}
|
||||
|
||||
location /god-mode/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://admin:3000/god-mode/;
|
||||
}
|
||||
|
||||
location /live/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://live:3000/;
|
||||
}
|
||||
|
||||
location /spaces/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"web",
|
||||
"space",
|
||||
"admin",
|
||||
"live",
|
||||
"packages/editor",
|
||||
"packages/eslint-config-custom",
|
||||
"packages/tailwind-config-custom",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
},
|
||||
"./lib": {
|
||||
"require": "./dist/lib.js",
|
||||
"types": "./dist/lib.d.mts",
|
||||
"import": "./dist/lib.mjs",
|
||||
"module": "./dist/lib.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -29,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@plane/ui": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { SlashCommand } from "@/extensions";
|
||||
// hooks
|
||||
import { TFileHandler } from "@/hooks/use-editor";
|
||||
// plane editor types
|
||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
import { TExtensions, TFileHandler } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export interface CompleteCollaboratorProviderConfiguration {
|
||||
/**
|
||||
* The identifier/name of your document
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The actual Y.js document
|
||||
*/
|
||||
document: Y.Doc;
|
||||
/**
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array, source?: string) => void;
|
||||
/**
|
||||
* Whether connection to the database has been established and all available content has been loaded or not.
|
||||
*/
|
||||
hasIndexedDBSynced: boolean;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
Partial<CompleteCollaboratorProviderConfiguration>;
|
||||
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
document: new Y.Doc(),
|
||||
onChange: () => {},
|
||||
hasIndexedDBSynced: false,
|
||||
};
|
||||
|
||||
unsyncedChanges = 0;
|
||||
|
||||
private initialSync = false;
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.indexeddbProvider = new IndexeddbPersistence(`page-${this.configuration.name}`, this.document);
|
||||
this.indexeddbProvider.on("synced", () => {
|
||||
this.configuration.hasIndexedDBSynced = true;
|
||||
});
|
||||
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||
this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||
}
|
||||
|
||||
private indexeddbProvider: IndexeddbPersistence;
|
||||
|
||||
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
get document() {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
public hasUnsyncedChanges(): boolean {
|
||||
return this.unsyncedChanges > 0;
|
||||
}
|
||||
|
||||
private resetUnsyncedChanges() {
|
||||
this.unsyncedChanges = 0;
|
||||
}
|
||||
|
||||
private incrementUnsyncedChanges() {
|
||||
this.unsyncedChanges += 1;
|
||||
}
|
||||
|
||||
public setSynced() {
|
||||
this.resetUnsyncedChanges();
|
||||
}
|
||||
|
||||
public async hasIndexedDBSynced() {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
return this.configuration.hasIndexedDBSynced;
|
||||
}
|
||||
|
||||
async documentUpdateHandler(_update: Uint8Array, origin: any) {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
// call onChange with the update
|
||||
const stateVector = Y.encodeStateAsUpdate(this.document);
|
||||
|
||||
if (!this.initialSync) {
|
||||
this.configuration.onChange?.(stateVector, "initialSync");
|
||||
this.initialSync = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuration.onChange?.(stateVector);
|
||||
this.incrementUnsyncedChanges();
|
||||
}
|
||||
|
||||
getUpdateFromIndexedDB(): Uint8Array {
|
||||
const update = Y.encodeStateAsUpdate(this.document);
|
||||
return update;
|
||||
}
|
||||
|
||||
documentDestroyHandler() {
|
||||
this.document.off("update", this.documentUpdateHandler);
|
||||
this.document.off("destroy", this.documentDestroyHandler);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./collaboration-provider";
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
import { IssueWidget } from "@/extensions";
|
||||
|
||||
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
const {
|
||||
aiHandler,
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
} = props;
|
||||
|
||||
const extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// use document editor
|
||||
const { editor } = useCollaborativeEditor({
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
});
|
||||
|
||||
const editorContainerClassNames = getEditorClassNames({
|
||||
noBorder: true,
|
||||
borderOnFocus: false,
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
aiHandler={aiHandler}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassNames}
|
||||
id={id}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
|
||||
(props, ref) => (
|
||||
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
)
|
||||
);
|
||||
|
||||
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
|
||||
|
||||
export { CollaborativeDocumentEditorWithRef };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types";
|
||||
|
||||
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
user,
|
||||
} = props;
|
||||
const extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const { editor } = useReadOnlyCollaborativeEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
user,
|
||||
});
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
id={id}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentReadOnlyEditorWithRef = forwardRef<
|
||||
EditorReadOnlyRefApi,
|
||||
ICollaborativeDocumentReadOnlyEditor
|
||||
>((props, ref) => (
|
||||
<CollaborativeDocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
CollaborativeDocumentReadOnlyEditorWithRef.displayName = "CollaborativeDocumentReadOnlyEditorWithRef";
|
||||
|
||||
export { CollaborativeDocumentReadOnlyEditorWithRef };
|
||||
@@ -1,105 +0,0 @@
|
||||
import React from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useDocumentEditor } from "@/hooks/use-document-editor";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import {
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TAIHandler,
|
||||
TDisplayConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
} from "@/types";
|
||||
|
||||
interface IDocumentEditor {
|
||||
aiHandler?: TAIHandler;
|
||||
containerClassName?: string;
|
||||
disabledExtensions?: TExtensions[];
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value: Uint8Array;
|
||||
}
|
||||
|
||||
const DocumentEditor = (props: IDocumentEditor) => {
|
||||
const {
|
||||
aiHandler,
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
// use document editor
|
||||
const { editor, isIndexedDbSynced } = useDocumentEditor({
|
||||
disabledExtensions,
|
||||
id,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
value,
|
||||
onChange,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
const editorContainerClassNames = getEditorClassNames({
|
||||
noBorder: true,
|
||||
borderOnFocus: false,
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor || !isIndexedDbSynced) return null;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
aiHandler={aiHandler}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassNames}
|
||||
id={id}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentEditorWithRef = React.forwardRef<EditorRefApi, IDocumentEditor>((props, ref) => (
|
||||
<DocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
|
||||
|
||||
export { DocumentEditorWithRef };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@/extensions";
|
||||
|
||||
/**
|
||||
* @description return an object with contentJSON and editorSchema
|
||||
* @description contentJSON- ProseMirror JSON from HTML content
|
||||
* @description editorSchema- editor schema from extensions
|
||||
* @param {string} html
|
||||
* @returns {object} {contentJSON, editorSchema}
|
||||
*/
|
||||
export const generateJSONfromHTMLForDocumentEditor = (html: string) => {
|
||||
const extensions = [...CoreEditorExtensionsWithoutProps(), ...DocumentEditorExtensionsWithoutProps()];
|
||||
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
|
||||
const editorSchema = getSchema(extensions as Extensions);
|
||||
return {
|
||||
contentJSON,
|
||||
editorSchema,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./editor";
|
||||
export * from "./collaborative-editor";
|
||||
export * from "./collaborative-read-only-editor";
|
||||
export * from "./page-renderer";
|
||||
export * from "./read-only-editor";
|
||||
export * from "./helpers";
|
||||
|
||||
@@ -9,8 +9,6 @@ import { IssueWidget } from "@/extensions";
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// plane web types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
|
||||
|
||||
@@ -20,7 +18,7 @@ interface IDocumentReadOnlyEditor {
|
||||
containerClassName: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: TEmbedConfig;
|
||||
embedHandler: any;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
@@ -36,41 +34,41 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
id,
|
||||
initialValue,
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
const extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
editorClassName,
|
||||
extensions: [
|
||||
embedHandler?.issue &&
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler?.issue.widgetCallback,
|
||||
}),
|
||||
],
|
||||
extensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
115
packages/editor/src/core/extensions/code/without-props.tsx
Normal file
115
packages/editor/src/core/extensions/code/without-props.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
// components
|
||||
import { CodeBlockLowlight } from "./code-block-lowlight";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use ProseMirror's insertText transaction to insert the tab character
|
||||
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowUp: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtStart = $from.parentOffset === 0;
|
||||
|
||||
if (!isAtStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if codeBlock is the first node
|
||||
const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0;
|
||||
|
||||
if (isFirstNode) {
|
||||
// Insert a new paragraph at the start of the document and move the cursor to it
|
||||
return editor.commands.command(({ tr }) => {
|
||||
const node = editor.schema.nodes.paragraph.create();
|
||||
tr.insert(0, node);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(1)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
|
||||
if (!isAtEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(Selection.near(doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
lowlight,
|
||||
defaultLanguage: "plaintext",
|
||||
exitOnTripleEnter: false,
|
||||
});
|
||||
@@ -3,28 +3,20 @@ import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// extensions
|
||||
import {
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
CustomHorizontalRule,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMentionWithoutProps,
|
||||
CustomQuoteExtension,
|
||||
CustomTypographyExtension,
|
||||
ImageExtensionWithoutProps,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
|
||||
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = () => [
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
@@ -53,7 +45,6 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||
class: "my-4 border-custom-border-400",
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
@@ -65,7 +56,6 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ImageExtensionWithoutProps().configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
@@ -84,20 +74,13 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeMarkPlugin,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CustomCodeBlockExtensionWithoutProps,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
@@ -136,7 +136,7 @@ export const CustomLinkExtension = Mark.create<LinkOptions>({
|
||||
{
|
||||
tag: "a[href]",
|
||||
getAttrs: (node) => {
|
||||
if (typeof node === "string" || !(node instanceof HTMLElement)) {
|
||||
if (typeof node === "string") {
|
||||
return null;
|
||||
}
|
||||
const href = node.getAttribute("href")?.toLowerCase() || "";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { IssueWidgetWithoutProps } from "@/extensions/issue-embed";
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()];
|
||||
@@ -8,7 +8,6 @@ export * from "./mentions";
|
||||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./core-without-props";
|
||||
export * from "./document-without-props";
|
||||
export * from "./custom-code-inline";
|
||||
export * from "./drop";
|
||||
export * from "./enter-key-extension";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MentionList, MentionNodeView } from "@/extensions";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
|
||||
export interface CustomMentionOptions extends MentionOptions {
|
||||
interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomMentionOptions, MentionNodeView } from "@/extensions";
|
||||
import Mention, { MentionOptions } from "@tiptap/extension-mention";
|
||||
// types
|
||||
import { IMentionHighlight } from "@/types";
|
||||
|
||||
interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const CustomMentionWithoutProps = () =>
|
||||
Mention.extend<CustomMentionOptions>({
|
||||
@@ -31,9 +35,6 @@ export const CustomMentionWithoutProps = () =>
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionNodeView);
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
|
||||
94
packages/editor/src/core/hooks/use-collaborative-editor.ts
Normal file
94
packages/editor/src/core/hooks/use-collaborative-editor.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// hooks
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
// plane editor extensions
|
||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TCollaborativeEditorProps } from "@/types";
|
||||
import { SideMenuExtension } from "@/extensions";
|
||||
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
} = props;
|
||||
// initialize Hocuspocus provider
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
name: id,
|
||||
parameters: realtimeConfig.queryParams,
|
||||
// using user id as a token to verify the user on the server
|
||||
token: user.id,
|
||||
url: realtimeConfig.url,
|
||||
onAuthenticationFailed: () => serverHandler?.onServerError?.(),
|
||||
onConnect: () => serverHandler?.onConnect?.(),
|
||||
onClose: (data) => {
|
||||
if (data.event.code === 1006) serverHandler?.onServerError?.();
|
||||
},
|
||||
}),
|
||||
[id, realtimeConfig, serverHandler, user.id]
|
||||
);
|
||||
|
||||
// destroy and disconnect connection on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
provider.destroy();
|
||||
provider.disconnect();
|
||||
},
|
||||
[provider]
|
||||
);
|
||||
// indexed db integration for offline support
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
enableHistory: false,
|
||||
fileHandler,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
extensions: [
|
||||
SideMenuExtension({
|
||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||
dragDropEnabled: true,
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
...(extensions ?? []),
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
issueEmbedConfig: embedHandler?.issue,
|
||||
}),
|
||||
],
|
||||
placeholder,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
return { editor };
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useLayoutEffect, useMemo, useState } from "react";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { IssueWidget, SideMenuExtension } from "@/extensions";
|
||||
// hooks
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
// plane editor extensions
|
||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// plane editor provider
|
||||
import { CollaborationProvider } from "@/plane-editor/providers";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
|
||||
|
||||
type DocumentEditorProps = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
embedHandler?: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value: Uint8Array;
|
||||
};
|
||||
|
||||
export const useDocumentEditor = (props: DocumentEditorProps) => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new CollaborationProvider({
|
||||
name: id,
|
||||
onChange,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[id]
|
||||
);
|
||||
|
||||
const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false);
|
||||
|
||||
// update document on value change from server
|
||||
useLayoutEffect(() => {
|
||||
if (value.length > 0) {
|
||||
Y.applyUpdate(provider.document, value);
|
||||
}
|
||||
}, [value, provider.document, id]);
|
||||
|
||||
// watch for indexedDb to complete syncing, only after which the editor is
|
||||
// rendered
|
||||
useLayoutEffect(() => {
|
||||
async function checkIndexDbSynced() {
|
||||
const hasSynced = await provider.hasIndexedDBSynced();
|
||||
setIndexedDbIsSynced(hasSynced);
|
||||
}
|
||||
checkIndexDbSynced();
|
||||
return () => {
|
||||
setIndexedDbIsSynced(false);
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
enableHistory: false,
|
||||
fileHandler,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
extensions: [
|
||||
SideMenuExtension({
|
||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||
dragDropEnabled: true,
|
||||
}),
|
||||
embedHandler?.issue &&
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
issueEmbedConfig: embedHandler?.issue,
|
||||
}),
|
||||
],
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
editor,
|
||||
isIndexedDbSynced,
|
||||
};
|
||||
};
|
||||
@@ -11,8 +11,6 @@ import { CoreEditorExtensions } from "@/extensions";
|
||||
import { getParagraphCount } from "@/helpers/common";
|
||||
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
|
||||
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
// plane editor providers
|
||||
import { CollaborationProvider } from "@/plane-editor/providers";
|
||||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
@@ -34,7 +32,6 @@ export interface CustomEditorProps {
|
||||
};
|
||||
onChange?: (json: object, html: string) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
provider?: CollaborationProvider;
|
||||
tabIndex?: number;
|
||||
// undefined when prop is not passed, null if intentionally passed to stop
|
||||
// swr syncing
|
||||
@@ -55,11 +52,14 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
// refs
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
const savedSelectionRef = useRef(savedSelection);
|
||||
const editor = useTiptapEditor({
|
||||
editorProps: {
|
||||
...CoreEditorProps({
|
||||
@@ -91,14 +91,6 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||
onDestroy: () => handleEditorReady?.(false),
|
||||
});
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
|
||||
// Inside your component or hook
|
||||
const savedSelectionRef = useRef(savedSelection);
|
||||
|
||||
// Update the ref whenever savedSelection changes
|
||||
useEffect(() => {
|
||||
savedSelectionRef.current = savedSelection;
|
||||
@@ -185,18 +177,6 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
},
|
||||
setSynced: () => {
|
||||
if (provider) {
|
||||
provider.setSynced();
|
||||
}
|
||||
},
|
||||
hasUnsyncedChanges: () => {
|
||||
if (provider) {
|
||||
return provider.hasUnsyncedChanges();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { TReadOnlyCollaborativeEditorProps } from "@/types";
|
||||
|
||||
export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEditorProps) => {
|
||||
const {
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
extensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
user,
|
||||
} = props;
|
||||
// initialize Hocuspocus provider
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
url: realtimeConfig.url,
|
||||
name: id,
|
||||
token: user.id,
|
||||
parameters: realtimeConfig.queryParams,
|
||||
onConnect: () => serverHandler?.onConnect?.(),
|
||||
onClose: (data) => {
|
||||
if (data.event.code === 1006) serverHandler?.onServerError?.();
|
||||
},
|
||||
}),
|
||||
[id, realtimeConfig, user.id]
|
||||
);
|
||||
// destroy and disconnect connection on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
provider.destroy();
|
||||
provider.disconnect();
|
||||
},
|
||||
[provider]
|
||||
);
|
||||
// indexed db integration for offline support
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
editorProps,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
extensions: [
|
||||
...(extensions ?? []),
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return { editor, isIndexedDbSynced: true };
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import { CoreReadOnlyEditorProps } from "@/props";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
initialValue: string;
|
||||
initialValue?: string;
|
||||
editorClassName: string;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
extensions?: any;
|
||||
|
||||
48
packages/editor/src/core/types/collaboration.ts
Normal file
48
packages/editor/src/core/types/collaboration.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TRealtimeConfig,
|
||||
TUserDetails,
|
||||
} from "@/types";
|
||||
|
||||
export type TServerHandler = {
|
||||
onConnect?: () => void;
|
||||
onServerError?: () => void;
|
||||
};
|
||||
|
||||
type TCollaborativeEditorHookProps = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: Extensions;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
};
|
||||
|
||||
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
embedHandler?: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
};
|
||||
@@ -1,8 +1,19 @@
|
||||
// helpers
|
||||
import { IMarking } from "@/helpers/scroll-to-node";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands, TFileHandler } from "@/types";
|
||||
import {
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TAIHandler,
|
||||
TDisplayConfig,
|
||||
TEditorCommands,
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TServerHandler,
|
||||
} from "@/types";
|
||||
|
||||
// editor refs
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
getHTML: () => string;
|
||||
@@ -23,12 +34,11 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
setSynced: () => void;
|
||||
hasUnsyncedChanges: () => boolean;
|
||||
getSelectedText: () => string | null;
|
||||
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
||||
}
|
||||
|
||||
// editor props
|
||||
export interface IEditorProps {
|
||||
containerClassName?: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
@@ -54,6 +64,19 @@ export interface IRichTextEditor extends IEditorProps {
|
||||
dragDropEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ICollaborativeDocumentEditor
|
||||
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
|
||||
aiHandler?: TAIHandler;
|
||||
disabledExtensions: TExtensions[];
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
}
|
||||
|
||||
// read only editor props
|
||||
export interface IReadOnlyEditorProps {
|
||||
containerClassName?: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
@@ -64,9 +87,35 @@ export interface IReadOnlyEditorProps {
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export interface ILiteTextReadOnlyEditor extends IReadOnlyEditorProps {}
|
||||
|
||||
export interface IRichTextReadOnlyEditor extends IReadOnlyEditorProps {}
|
||||
|
||||
export interface ICollaborativeDocumentReadOnlyEditor extends Omit<IReadOnlyEditorProps, "initialValue"> {
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
}
|
||||
|
||||
export interface IDocumentReadOnlyEditor extends IReadOnlyEditorProps {
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export type TUserDetails = {
|
||||
color: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TRealtimeConfig = {
|
||||
url: string;
|
||||
queryParams: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./ai";
|
||||
export * from "./collaboration";
|
||||
export * from "./config";
|
||||
export * from "./editor";
|
||||
export * from "./embed";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "src/ce/providers";
|
||||
@@ -7,7 +7,8 @@ import "src/styles/drag-drop.css";
|
||||
|
||||
// editors
|
||||
export {
|
||||
DocumentEditorWithRef,
|
||||
CollaborativeDocumentEditorWithRef,
|
||||
CollaborativeDocumentReadOnlyEditorWithRef,
|
||||
DocumentReadOnlyEditorWithRef,
|
||||
LiteTextEditorWithRef,
|
||||
LiteTextReadOnlyEditorWithRef,
|
||||
@@ -19,7 +20,6 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||
|
||||
// helpers
|
||||
export * from "@/helpers/common";
|
||||
export * from "@/components/editors/document/helpers";
|
||||
export * from "@/helpers/editor-commands";
|
||||
export * from "@/helpers/yjs";
|
||||
export * from "@/extensions/table/table";
|
||||
|
||||
1
packages/editor/src/lib.ts
Normal file
1
packages/editor/src/lib.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@/extensions/core-without-props";
|
||||
@@ -1,10 +1,10 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
entry: ["src/index.ts", "src/lib.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
clean: true,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
|
||||
@@ -16,7 +16,5 @@
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
|
||||
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
@@ -49,6 +50,7 @@
|
||||
"@storybook/react": "^8.1.1",
|
||||
"@storybook/react-webpack5": "^8.1.1",
|
||||
"@storybook/test": "^8.1.1",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.5.2",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-color": "^3.0.9",
|
||||
@@ -63,7 +65,7 @@
|
||||
"tailwind-config-custom": "*",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^5.10.1",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/ui/tsup.config.ts
Normal file
11
packages/ui/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
||||
1
setup.sh
1
setup.sh
@@ -9,6 +9,7 @@ cp ./web/.env.example ./web/.env
|
||||
cp ./apiserver/.env.example ./apiserver/.env
|
||||
cp ./space/.env.example ./space/.env
|
||||
cp ./admin/.env.example ./admin/.env
|
||||
cp ./live/.env.example ./live/.env
|
||||
|
||||
# Generate the SECRET_KEY that will be used by django
|
||||
echo -e "\nSECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.38.0",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-popper": "^2.3.0",
|
||||
"swr": "^2.2.2",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
@@ -63,4 +63,4 @@
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"NEXT_PUBLIC_SPACE_BASE_URL",
|
||||
"NEXT_PUBLIC_SPACE_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEB_BASE_URL",
|
||||
"NEXT_PUBLIC_LIVE_BASE_URL",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_CRISP_ID",
|
||||
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
|
||||
|
||||
@@ -5,3 +5,6 @@ NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
|
||||
NEXT_PUBLIC_LIVE_BASE_URL=""
|
||||
NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
@@ -39,6 +39,12 @@ ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_LIVE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
@@ -77,6 +83,12 @@ ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_LIVE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
// types
|
||||
import { TLogoProps } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
import { Breadcrumbs, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { PageEditInformationPopover } from "@/components/pages";
|
||||
@@ -27,17 +27,14 @@ export interface IPagesHeaderProps {
|
||||
export const PageDetailsHeader = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, pageId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// store hooks
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const page = usePage(pageId?.toString() ?? "");
|
||||
const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = page;
|
||||
const { name, logo_props, updatePageLogo } = page;
|
||||
// use platform
|
||||
const { isMobile, platform } = usePlatformOS();
|
||||
// derived values
|
||||
const isMac = platform === "MacOS";
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const handlePageLogoUpdate = async (data: TLogoProps) => {
|
||||
if (data) {
|
||||
@@ -60,7 +57,6 @@ export const PageDetailsHeader = observer(() => {
|
||||
};
|
||||
|
||||
const pageTitle = getPageName(name);
|
||||
const isVersionHistoryOverlayActive = !!searchParams.get("version");
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||
@@ -165,25 +161,6 @@ export const PageDetailsHeader = observer(() => {
|
||||
</div>
|
||||
<PageEditInformationPopover page={page} />
|
||||
<PageDetailsHeaderExtraActions />
|
||||
{isContentEditable && !isVersionHistoryOverlayActive && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// ctrl/cmd + s to save the changes
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "s",
|
||||
ctrlKey: !isMac,
|
||||
metaKey: isMac,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
className="flex-shrink-0 w-24"
|
||||
loading={isSubmitting === "submitting"}
|
||||
>
|
||||
{isSubmitting === "submitting" ? "Saving" : "Save changes"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// document-editor
|
||||
import {
|
||||
DocumentEditorWithRef,
|
||||
DocumentReadOnlyEditorWithRef,
|
||||
CollaborativeDocumentEditorWithRef,
|
||||
CollaborativeDocumentReadOnlyEditorWithRef,
|
||||
EditorReadOnlyRefApi,
|
||||
EditorRefApi,
|
||||
IMarking,
|
||||
TAIMenuProps,
|
||||
TDisplayConfig,
|
||||
TRealtimeConfig,
|
||||
TServerHandler,
|
||||
} from "@plane/editor";
|
||||
// types
|
||||
import { IUserLite } from "@plane/types";
|
||||
// components
|
||||
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { cn, LIVE_URL } from "@/helpers/common.helper";
|
||||
import { generateRandomColor } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
@@ -34,31 +37,27 @@ const fileService = new FileService();
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
markings: IMarking[];
|
||||
page: IPage;
|
||||
sidePeekVisible: boolean;
|
||||
handleConnectionStatus: (status: boolean) => void;
|
||||
handleEditorReady: (value: boolean) => void;
|
||||
handleReadOnlyEditorReady: (value: boolean) => void;
|
||||
markings: IMarking[];
|
||||
page: IPage;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
sidePeekVisible: boolean;
|
||||
updateMarkings: (description_html: string) => void;
|
||||
handleDescriptionChange: (update: Uint8Array, source?: string | undefined) => void;
|
||||
isDescriptionReady: boolean;
|
||||
pageDescriptionYJS: Uint8Array | undefined;
|
||||
};
|
||||
|
||||
export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
handleReadOnlyEditorReady,
|
||||
handleEditorReady,
|
||||
editorRef,
|
||||
handleConnectionStatus,
|
||||
handleEditorReady,
|
||||
handleReadOnlyEditorReady,
|
||||
markings,
|
||||
readOnlyEditorRef,
|
||||
page,
|
||||
readOnlyEditorRef,
|
||||
sidePeekVisible,
|
||||
updateMarkings,
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
@@ -101,12 +100,38 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
[editorRef]
|
||||
);
|
||||
|
||||
const handleServerConnect = useCallback(() => {
|
||||
handleConnectionStatus(false);
|
||||
}, []);
|
||||
const handleServerError = useCallback(() => {
|
||||
handleConnectionStatus(true);
|
||||
}, []);
|
||||
|
||||
const serverHandler: TServerHandler = useMemo(
|
||||
() => ({
|
||||
onConnect: handleServerConnect,
|
||||
onServerError: handleServerError,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateMarkings(pageDescription ?? "<p></p>");
|
||||
}, [pageDescription, updateMarkings]);
|
||||
|
||||
if (pageId === undefined || pageDescription === undefined || !pageDescriptionYJS || !isDescriptionReady)
|
||||
return <PageContentLoader />;
|
||||
const realtimeConfig: TRealtimeConfig = useMemo(
|
||||
() => ({
|
||||
url: `${LIVE_URL}/collaboration`,
|
||||
queryParams: {
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: projectId?.toString(),
|
||||
documentType: "project_page",
|
||||
},
|
||||
}),
|
||||
[projectId, workspaceSlug]
|
||||
);
|
||||
|
||||
if (pageId === undefined) return <PageContentLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full w-full overflow-y-auto">
|
||||
@@ -140,7 +165,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
{isContentEditable ? (
|
||||
<DocumentEditorWithRef
|
||||
<CollaborativeDocumentEditorWithRef
|
||||
id={pageId}
|
||||
fileHandler={{
|
||||
cancel: fileService.cancelUpload,
|
||||
@@ -149,12 +174,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
|
||||
}}
|
||||
handleEditorReady={handleEditorReady}
|
||||
value={pageDescriptionYJS}
|
||||
ref={editorRef}
|
||||
containerClassName="p-0 pb-64"
|
||||
displayConfig={displayConfig}
|
||||
editorClassName="pl-10"
|
||||
onChange={handleDescriptionChange}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
@@ -162,16 +185,22 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
embedHandler={{
|
||||
issue: issueEmbedProps,
|
||||
}}
|
||||
realtimeConfig={realtimeConfig}
|
||||
serverHandler={serverHandler}
|
||||
user={{
|
||||
id: currentUser?.id ?? "",
|
||||
name: currentUser?.display_name ?? "",
|
||||
color: generateRandomColor(currentUser?.id ?? ""),
|
||||
}}
|
||||
disabledExtensions={documentEditor}
|
||||
aiHandler={{
|
||||
menu: getAIMenu,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
ref={readOnlyEditorRef}
|
||||
<CollaborativeDocumentReadOnlyEditorWithRef
|
||||
id={pageId}
|
||||
initialValue={pageDescription ?? "<p></p>"}
|
||||
ref={readOnlyEditorRef}
|
||||
handleEditorReady={handleReadOnlyEditorReady}
|
||||
containerClassName="p-0 pb-64 border-none"
|
||||
displayConfig={displayConfig}
|
||||
@@ -184,6 +213,13 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
widgetCallback: issueEmbedProps.widgetCallback,
|
||||
},
|
||||
}}
|
||||
realtimeConfig={realtimeConfig}
|
||||
serverHandler={serverHandler}
|
||||
user={{
|
||||
id: currentUser?.id ?? "",
|
||||
name: currentUser?.display_name ?? "",
|
||||
color: generateRandomColor(currentUser?.id ?? ""),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Sparkle } from "lucide-react";
|
||||
import { CircleAlert, Sparkle } from "lucide-react";
|
||||
// editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
// ui
|
||||
import { ArchiveIcon } from "@plane/ui";
|
||||
import { ArchiveIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { GptAssistantPopover } from "@/components/core";
|
||||
import { LockedComponent } from "@/components/icons/locked-component";
|
||||
@@ -15,25 +15,28 @@ import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
import useOnlineStatus from "@/hooks/use-online-status";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
hasConnectionFailed: boolean;
|
||||
page: IPage;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef, handleSaveDescription } = props;
|
||||
const { editorRef, handleDuplicatePage, hasConnectionFailed, page, readOnlyEditorRef } = props;
|
||||
// states
|
||||
const [gptModalOpen, setGptModal] = useState(false);
|
||||
// store hooks
|
||||
const { config } = useInstance();
|
||||
// derived values
|
||||
const { archived_at, isContentEditable, is_locked } = page;
|
||||
// use online status
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const handleAiAssistance = async (response: string) => {
|
||||
if (!editorRef) return;
|
||||
@@ -45,10 +48,32 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
{is_locked && <LockedComponent />}
|
||||
{archived_at && (
|
||||
<div className="flex h-7 items-center gap-2 rounded-full bg-blue-500/20 px-3 py-0.5 text-xs font-medium text-blue-500">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<ArchiveIcon className="flex-shrink-0 size-3" />
|
||||
<span>Archived at {renderFormattedDate(archived_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
{isContentEditable && !isOnline && (
|
||||
<Tooltip
|
||||
tooltipHeading="You are offline"
|
||||
tooltipContent="All changes made will be saved locally and will be synced when the internet connection is re-established."
|
||||
>
|
||||
<div className="flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
|
||||
<span className="flex-shrink-0 size-1.5 rounded-full bg-custom-text-300" />
|
||||
<span>Offline</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasConnectionFailed && isOnline && (
|
||||
<Tooltip
|
||||
tooltipHeading="Connection failed"
|
||||
tooltipContent="All changes made will be saved locally and will be synced when the connection is re-established."
|
||||
>
|
||||
<div className="flex h-7 items-center gap-2 rounded-full bg-red-500/20 px-3 py-0.5 text-xs font-medium text-red-500">
|
||||
<CircleAlert className="flex-shrink-0 size-3" />
|
||||
<span>Server error</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isContentEditable && config?.has_openai_configured && (
|
||||
<GptAssistantPopover
|
||||
isOpen={gptModalOpen}
|
||||
@@ -77,7 +102,6 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,30 +8,30 @@ import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
type Props = {
|
||||
editorReady: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
hasConnectionFailed: boolean;
|
||||
markings: IMarking[];
|
||||
page: IPage;
|
||||
sidePeekVisible: boolean;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
editorReady: boolean;
|
||||
readOnlyEditorReady: boolean;
|
||||
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
sidePeekVisible: boolean;
|
||||
};
|
||||
|
||||
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
editorRef,
|
||||
readOnlyEditorRef,
|
||||
editorReady,
|
||||
markings,
|
||||
readOnlyEditorReady,
|
||||
editorRef,
|
||||
handleDuplicatePage,
|
||||
hasConnectionFailed,
|
||||
markings,
|
||||
page,
|
||||
sidePeekVisible,
|
||||
readOnlyEditorReady,
|
||||
readOnlyEditorRef,
|
||||
setSidePeekVisible,
|
||||
handleSaveDescription,
|
||||
sidePeekVisible,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
@@ -54,8 +54,8 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
hasConnectionFailed={hasConnectionFailed}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
|
||||
@@ -19,11 +19,10 @@ type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, page, handleSaveDescription } = props;
|
||||
const { editorRef, handleDuplicatePage, page } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store values
|
||||
@@ -82,11 +81,6 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
})
|
||||
);
|
||||
|
||||
const saveDescriptionYJSAndPerformAction = (action: () => void) => async () => {
|
||||
await handleSaveDescription();
|
||||
action();
|
||||
};
|
||||
|
||||
// menu items list
|
||||
const MENU_ITEMS: {
|
||||
key: string;
|
||||
@@ -131,21 +125,21 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
action: saveDescriptionYJSAndPerformAction(handleDuplicatePage),
|
||||
action: handleDuplicatePage,
|
||||
label: "Make a copy",
|
||||
icon: Copy,
|
||||
shouldRender: canCurrentUserDuplicatePage,
|
||||
},
|
||||
{
|
||||
key: "lock-unlock-page",
|
||||
action: is_locked ? handleUnlockPage : saveDescriptionYJSAndPerformAction(handleLockPage),
|
||||
action: is_locked ? handleUnlockPage : handleLockPage,
|
||||
label: is_locked ? "Unlock page" : "Lock page",
|
||||
icon: is_locked ? LockOpen : Lock,
|
||||
shouldRender: canCurrentUserLockPage,
|
||||
},
|
||||
{
|
||||
key: "archive-restore-page",
|
||||
action: archived_at ? handleRestorePage : saveDescriptionYJSAndPerformAction(handleArchivePage),
|
||||
action: archived_at ? handleRestorePage : handleArchivePage,
|
||||
label: archived_at ? "Restore page" : "Archive page",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
|
||||
@@ -10,30 +10,30 @@ import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
type Props = {
|
||||
editorReady: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
hasConnectionFailed: boolean;
|
||||
markings: IMarking[];
|
||||
page: IPage;
|
||||
sidePeekVisible: boolean;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
editorReady: boolean;
|
||||
readOnlyEditorReady: boolean;
|
||||
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
sidePeekVisible: boolean;
|
||||
};
|
||||
|
||||
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
editorRef,
|
||||
readOnlyEditorRef,
|
||||
editorReady,
|
||||
markings,
|
||||
readOnlyEditorReady,
|
||||
editorRef,
|
||||
handleDuplicatePage,
|
||||
hasConnectionFailed,
|
||||
markings,
|
||||
page,
|
||||
sidePeekVisible,
|
||||
readOnlyEditorReady,
|
||||
readOnlyEditorRef,
|
||||
setSidePeekVisible,
|
||||
handleSaveDescription,
|
||||
sidePeekVisible,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
@@ -65,20 +65,20 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
hasConnectionFailed={hasConnectionFailed}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<PageEditorMobileHeaderRoot
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
editorRef={editorRef}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
editorReady={editorReady}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
markings={markings}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
hasConnectionFailed={hasConnectionFailed}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={setSidePeekVisible}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// plane editor
|
||||
// editor
|
||||
import { EditorRefApi, useEditorMarkings } from "@plane/editor";
|
||||
// plane types
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
// plane ui
|
||||
// ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay, PagesVersionEditor } from "@/components/pages";
|
||||
// hooks
|
||||
import { useProjectPages } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePageDescription } from "@/hooks/use-page-description";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// services
|
||||
import { ProjectPageVersionService } from "@/services/page";
|
||||
@@ -31,6 +30,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
// states
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false);
|
||||
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768);
|
||||
const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false);
|
||||
// refs
|
||||
@@ -46,19 +46,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
const { access, description_html, name, isContentEditable } = page;
|
||||
// editor markings hook
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
// project-description
|
||||
const {
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
handleSaveDescription,
|
||||
manuallyUpdateDescription,
|
||||
} = usePageDescription({
|
||||
editorRef,
|
||||
page,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
// update query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
|
||||
@@ -98,6 +85,11 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
router.push(updatedRoute);
|
||||
};
|
||||
|
||||
const handleRestoreVersion = async (descriptionHTML: string) => {
|
||||
editorRef.current?.clearEditor();
|
||||
editorRef.current?.setEditorValue(descriptionHTML);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageVersionsOverlay
|
||||
@@ -120,36 +112,34 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
versionId
|
||||
);
|
||||
}}
|
||||
handleRestore={manuallyUpdateDescription}
|
||||
handleRestore={handleRestoreVersion}
|
||||
isOpen={isVersionsOverlayOpen}
|
||||
onClose={handleCloseVersionsOverlay}
|
||||
pageId={page.id ?? ""}
|
||||
restoreEnabled={isContentEditable}
|
||||
/>
|
||||
<PageEditorHeaderRoot
|
||||
editorRef={editorRef}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
editorReady={editorReady}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
hasConnectionFailed={hasConnectionFailed}
|
||||
markings={markings}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
/>
|
||||
<PageEditorBody
|
||||
editorRef={editorRef}
|
||||
handleConnectionStatus={(status) => setHasConnectionFailed(status)}
|
||||
handleEditorReady={(val) => setEditorReady(val)}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
|
||||
markings={markings}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
updateMarkings={updateMarkings}
|
||||
handleDescriptionChange={handleDescriptionChange}
|
||||
isDescriptionReady={isDescriptionReady}
|
||||
pageDescriptionYJS={pageDescriptionYJS}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
22
web/core/hooks/use-online-status.ts
Normal file
22
web/core/hooks/use-online-status.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const useOnlineStatus = () => {
|
||||
// states
|
||||
const [isOnline, setIsOnline] = useState(typeof navigator !== "undefined" ? navigator.onLine : true);
|
||||
|
||||
const updateOnlineStatus = () => setIsOnline(navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("online", updateOnlineStatus);
|
||||
window.addEventListener("offline", updateOnlineStatus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", updateOnlineStatus);
|
||||
window.removeEventListener("offline", updateOnlineStatus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isOnline };
|
||||
};
|
||||
|
||||
export default useOnlineStatus;
|
||||
@@ -1,207 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
// plane editor
|
||||
import {
|
||||
EditorRefApi,
|
||||
proseMirrorJSONToBinaryString,
|
||||
applyUpdates,
|
||||
generateJSONfromHTMLForDocumentEditor,
|
||||
} from "@plane/editor";
|
||||
// hooks
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import useAutoSave from "@/hooks/use-auto-save";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// services
|
||||
import { ProjectPageService } from "@/services/page";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
const projectPageService = new ProjectPageService();
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
page: IPage;
|
||||
projectId: string | string[] | undefined;
|
||||
workspaceSlug: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export const usePageDescription = (props: Props) => {
|
||||
const { editorRef, page, projectId, workspaceSlug } = props;
|
||||
const [isDescriptionReady, setIsDescriptionReady] = useState(false);
|
||||
const [localDescriptionYJS, setLocalDescriptionYJS] = useState<Uint8Array>();
|
||||
const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page;
|
||||
const [hasShownOfflineToast, setHasShownOfflineToast] = useState(false);
|
||||
|
||||
const pageDescription = page.description_html;
|
||||
const pageId = page.id;
|
||||
|
||||
const { data: pageDescriptionYJS, mutate: mutateDescriptionYJS } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? `PAGE_DESCRIPTION_${workspaceSlug}_${projectId}_${pageId}` : null,
|
||||
workspaceSlug && projectId && pageId
|
||||
? async () => {
|
||||
const encodedDescription = await projectPageService.fetchDescriptionYJS(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
pageId.toString()
|
||||
);
|
||||
const decodedDescription = new Uint8Array(encodedDescription);
|
||||
return decodedDescription;
|
||||
}
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
}
|
||||
);
|
||||
|
||||
// set the merged local doc by the provider to the react local state
|
||||
const handleDescriptionChange = useCallback((update: Uint8Array, source?: string) => {
|
||||
setLocalDescriptionYJS(() => {
|
||||
// handle the initial sync case where indexeddb gives extra update, in
|
||||
// this case we need to save the update to the DB
|
||||
if (source && source === "initialSync") {
|
||||
handleSaveDescription(true, update);
|
||||
}
|
||||
|
||||
return update;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// if description_binary field is empty, convert description_html to yDoc and update the DB
|
||||
// TODO: this is a one-time operation, and needs to be removed once all the pages are updated
|
||||
useEffect(() => {
|
||||
const changeHTMLToBinary = async () => {
|
||||
if (!pageDescriptionYJS || !pageDescription) return;
|
||||
if (pageDescriptionYJS.length === 0) {
|
||||
const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(pageDescription ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
try {
|
||||
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
}
|
||||
|
||||
await mutateDescriptionYJS();
|
||||
|
||||
setIsDescriptionReady(true);
|
||||
} else setIsDescriptionReady(true);
|
||||
};
|
||||
changeHTMLToBinary();
|
||||
}, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]);
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef?.current?.hasUnsyncedChanges() || isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
} else {
|
||||
setShowAlert(false);
|
||||
}
|
||||
}, [setShowAlert, isSubmitting, editorRef, localDescriptionYJS]);
|
||||
|
||||
// merge the description from remote to local state and only save if there are local changes
|
||||
const handleSaveDescription = useCallback(
|
||||
async (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array) => {
|
||||
const update = localDescriptionYJS ?? initSyncVectorAsUpdate;
|
||||
|
||||
if (update == null) return;
|
||||
|
||||
if (!isContentEditable) return;
|
||||
|
||||
const applyUpdatesAndSave = async (latestDescription: Uint8Array, update: Uint8Array | undefined) => {
|
||||
if (!workspaceSlug || !projectId || !pageId || !latestDescription || !update) return;
|
||||
|
||||
if (!forceSync && !editorRef.current?.hasUnsyncedChanges()) {
|
||||
setIsSubmitting("saved");
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedBinaryString = applyUpdates(latestDescription, update);
|
||||
const descriptionHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
await updateDescription(combinedBinaryString, descriptionHTML)
|
||||
.then(() => {
|
||||
editorRef.current?.setSynced();
|
||||
setHasShownOfflineToast(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.message === "Network Error" && !hasShownOfflineToast) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Info!",
|
||||
message: "You seem to be offline, your changes will remain saved on this device",
|
||||
});
|
||||
setHasShownOfflineToast(true);
|
||||
}
|
||||
if (e.response?.status === 471) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to save your changes, the page was locked, your changes will be lost",
|
||||
});
|
||||
}
|
||||
if (e.response?.status === 472) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to save your changes, the page was archived, your changes will be lost",
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setShowAlert(false);
|
||||
setIsSubmitting("saved");
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSubmitting("submitting");
|
||||
const latestDescription = await mutateDescriptionYJS();
|
||||
if (latestDescription) {
|
||||
await applyUpdatesAndSave(latestDescription, update);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsSubmitting("saved");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
localDescriptionYJS,
|
||||
setShowAlert,
|
||||
editorRef,
|
||||
hasShownOfflineToast,
|
||||
isContentEditable,
|
||||
mutateDescriptionYJS,
|
||||
pageId,
|
||||
projectId,
|
||||
setIsSubmitting,
|
||||
updateDescription,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
const manuallyUpdateDescription = async (descriptionHTML: string) => {
|
||||
const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(descriptionHTML ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
try {
|
||||
editorRef.current?.clearEditor(true);
|
||||
await updateDescription(yDocBinaryString, descriptionHTML ?? "<p></p>");
|
||||
await mutateDescriptionYJS();
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
}
|
||||
};
|
||||
|
||||
useAutoSave(handleSaveDescription);
|
||||
|
||||
return {
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
handleSaveDescription,
|
||||
manuallyUpdateDescription,
|
||||
};
|
||||
};
|
||||
@@ -9,6 +9,10 @@ export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
|
||||
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
|
||||
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
|
||||
|
||||
const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
|
||||
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
|
||||
export const LIVE_URL = `${LIVE_BASE_URL}${LIVE_BASE_PATH}`;
|
||||
|
||||
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
|
||||
|
||||
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
|
||||
|
||||
Reference in New Issue
Block a user