[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:
Aaryan Khandelwal
2024-09-02 17:54:12 +05:30
committed by GitHub
parent 2c950713a7
commit 6c3a8a9647
71 changed files with 4135 additions and 4105 deletions

View File

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

View File

@@ -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",

View File

@@ -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(

View File

@@ -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}

View File

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

View File

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

@@ -0,0 +1 @@
API_BASE_URL="http://api:8000"

13
live/Dockerfile.dev Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/ban-types
export type TAdditionalDocumentTypes = {}

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

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

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

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

@@ -0,0 +1 @@
export * from "../../ce/types/common.js"

123
live/src/server.ts Normal file
View 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
View 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
View 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,
}));

View File

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

View File

@@ -7,6 +7,7 @@
"web",
"space",
"admin",
"live",
"packages/editor",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",

View File

@@ -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",

View File

@@ -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[];

View File

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

View File

@@ -1 +0,0 @@
export * from "./collaboration-provider";

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
};
};

View File

@@ -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";

View File

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

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

View File

@@ -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()];

View File

@@ -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() || "";

View File

@@ -1,3 +0,0 @@
import { IssueWidgetWithoutProps } from "@/extensions/issue-embed";
export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()];

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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 [
{

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

View File

@@ -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,
};
};

View File

@@ -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) {

View File

@@ -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 };
};

View File

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

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

View File

@@ -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;
};
};

View File

@@ -1,4 +1,5 @@
export * from "./ai";
export * from "./collaboration";
export * from "./config";
export * from "./editor";
export * from "./embed";

View File

@@ -1 +0,0 @@
export * from "src/ce/providers";

View File

@@ -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";

View File

@@ -0,0 +1 @@
export * from "@/extensions/core-without-props";

View File

@@ -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,

View File

@@ -16,7 +16,5 @@
"skipLibCheck": true,
"strict": true
},
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}

View File

@@ -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"
}
}

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

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>

View File

@@ -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,

View File

@@ -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}

View File

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

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

View File

@@ -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,
};
};

View File

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

5868
yarn.lock

File diff suppressed because it is too large Load Diff