Compare commits

..
Author SHA1 Message Date
Palanikannan M 4eb5b4f150 fix: more live server stuff 2025-06-27 15:30:59 +05:30
Palanikannan M c063eaeedb fix: live restructuring 2025-06-24 17:39:23 +05:30
Palanikannan M 24a3c08758 fix: package.json 2025-06-23 18:52:50 +05:30
Palanikannan M 829fc4752f Merge branch 'preview' into fix/live-server-restructuring 2025-06-23 18:49:19 +05:30
sriram veeraghanta 18a3771315 fix: removed unused dependencies 2025-04-03 20:50:29 +05:30
sriram veeraghanta 31c09d5a48 Merge branch 'preview' of github.com:makeplane/plane into fix/live-server-restructuring 2025-04-03 20:25:16 +05:30
Palanikannan M 1315acf952 fix: redis extension fixes 2025-04-03 17:56:33 +05:30
Palanikannan M 728f517cb1 fix: logger reference 2025-04-03 17:47:56 +05:30
Palanikannan M cb3b6fbd8d Merge branch 'preview' into fix/live-server-restructuring 2025-04-03 17:37:38 +05:30
Palanikannan M 70dbad648d chore: remove services 2025-04-03 17:37:03 +05:30
Palanikannan M ad9888ce45 fix: registering handlers made simplified 2025-04-02 15:31:05 +05:30
Palanikannan M 4eef115fcd Merge branch 'preview' into fix/live-server-restructuring 2025-04-02 13:19:29 +05:30
Palanikannan M cabd5f4275 fix: moving around pages 2025-04-02 13:10:47 +05:30
Palanikannan M 7cbdcd4c94 fix: added zod validation 2025-03-26 19:58:32 +05:30
Palanikannan M cef60b55e4 fix: error factory types and document controller error handling 2025-03-26 15:52:36 +05:30
Palanikannan M 07bf68c8ca fix: exit with error 1 2025-03-26 14:55:41 +05:30
Palanikannan M 3d07d0f678 fix: add missing packages 2025-03-26 14:52:11 +05:30
Palanikannan M 6c2fb4b287 merge decorators 2025-03-26 14:40:55 +05:30
Palanikannan M c1b8feaf6f chore: Merge preview 2025-03-26 14:01:50 +05:30
Palanikannan M 8a9cdc6133 fix: refactor decorators 2025-03-26 13:59:28 +05:30
Palanikannan M 6db75fe29c fix: added package dependency 2025-03-26 00:05:19 +05:30
Palanikannan M ea7ebe66b1 feat: express decorators for rest apis and websocket 2025-03-25 20:30:47 +05:30
Palanikannan M 93066ef5d5 Merge branch 'fix/live-server-restructuring' into fix/live-server-restructuring 2025-03-25 19:46:58 +05:30
Palanikannan M 2db9d35678 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-25 19:42:44 +05:30
Palanikannan M 37cd01e306 feat: express decorators for rest apis and websocket 2025-03-25 17:51:53 +05:30
Palanikannan M d3defc9785 fix: redis process improved a lot 2025-03-24 22:03:59 +05:30
Palanikannan M 3a0891e0ee fix: extensions index ts cleaned up 2025-03-22 03:06:12 +05:30
Palanikannan M 53efad3399 chore: restructuring project-page methods, transformers and handlers 2025-03-22 02:23:37 +05:30
Palanikannan M 304ef1a80c Merge branch 'preview' into fix/live-server-restructuring 2025-03-22 01:51:37 +05:30
Palanikannan M 16d41a3841 fix: not shutting down the app for any reason 2025-03-22 01:51:28 +05:30
Palanikannan M a9f4427b21 fix: seperated decorators into it's own package 2025-03-21 03:04:19 +05:30
Palanikannan M e4f31aea08 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-19 16:01:26 +05:30
Palanikannan M c2a3e47d3d fix: stop event prop on error 2025-03-19 16:01:09 +05:30
Palanikannan M cef4110eb0 fix: handlers 2025-03-19 14:01:19 +05:30
Palanikannan M 3672ee4ef1 fix: errors and imports 2025-03-18 19:25:31 +05:30
Palanikannan M c56097b8c0 fix: better error handling for redis client 2025-03-18 18:56:50 +05:30
Palanikannan M 0d57e0ab32 fix: file structure for error handling 2025-03-18 17:32:46 +05:30
Palanikannan M df35ccecc9 fix: better error handling 2025-03-18 16:59:04 +05:30
Palanikannan M 38d8d3ea9b fix: dividing server code 2025-03-18 01:36:14 +05:30
Palanikannan M 3710b182d3 fix: tsup hot reloading 2025-03-18 00:16:30 +05:30
Palanikannan M 6897575a62 fix: logger and added working global error handling with sentry 2025-03-17 23:24:03 +05:30
Palanikannan M 388151b70b Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-17 15:52:49 +05:30
Palanikannan M 3d61604569 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-02-08 20:32:50 +05:30
Palanikannan M 1b29f65664 fix: removed .js imports 2024-12-23 18:16:53 +05:30
Palanikannan M a229508611 Merge branch 'preview' into devin/1734544044-refactor-live-server 2024-12-23 17:23:59 +05:30
Devin AIandsriram@plane.so <sriram@plane.so> d5bd4ef63a chore: switch from esbuild to tsup for build configuration
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-19 07:49:27 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 146332fff3 chore: replace babel with esbuild for build configuration
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-19 07:42:19 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 5802858772 fix: resolve typescript errors in server and decorators
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:05:01 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> b39ce9c18a fix: add eslint config and fix websocket router interface
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:03:29 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> a7ab5ae680 fix: resolve lint errors in decorators and collaboration controller
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:02:48 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> f2a08853e2 feat: add start.ts entry point for live server
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:01:12 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 23eeb45713 feat: implement collaboration controller with websocket support
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:54:15 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 6c83a0df09 feat: implement health check controller with decorator support
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:52:40 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> dbee7488e1 feat: add decorator system and controller registration for live server
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:50:52 +00:00
202 changed files with 7659 additions and 9040 deletions
-23
View File
@@ -1,23 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
],
"@babel/preset-typescript"
],
"plugins": [
[
"module-resolver",
{
"root": ["./src"],
"alias": {
"@/core": "./src/core",
"@/plane-live": "./src/ce"
}
}
]
]
}
+17 -17
View File
@@ -3,13 +3,15 @@
"version": "0.26.1",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
"main": "./dist/start.js",
"module": "./dist/start.mjs",
"types": "./dist/start.d.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "PORT=3100 concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"dev": "tsup --watch --onSuccess 'node --env-file=.env dist/start.js'",
"build": "tsup",
"start": "node --env-file=.env dist/start.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
@@ -21,14 +23,17 @@
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@plane/constants": "*",
"@plane/decorators": "*",
"@plane/editor": "*",
"@plane/logger": "*",
"@plane/types": "*",
"@tiptap/core": "^2.22.3",
"@tiptap/html": "^2.22.3",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
@@ -37,26 +42,21 @@
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"reflect-metadata": "^0.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
"yjs": "^13.6.20",
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"@types/node": "22.5.4",
"tsup": "8.4.0",
"typescript": "5.8.3"
}
@@ -0,0 +1,96 @@
import type { Request } from "express";
import type { WebSocket as WS } from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import { ErrorCategory } from "@/core/helpers/error-handling/error-handler";
import { logger } from "@plane/logger";
import Errors from "@/core/helpers/error-handling/error-factory";
import { Controller, WebSocket } from "@plane/decorators";
@Controller("/collaboration")
export class CollaborationController {
private metrics = {
errors: 0,
};
constructor(private readonly hocusPocusServer: Hocuspocus) {}
@WebSocket("/")
handleConnection(ws: WS, req: Request) {
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId: req.id || crypto.randomUUID(),
};
try {
// Initialize the connection with Hocuspocus
this.hocusPocusServer.handleConnection(ws, req);
// Set up error handling for the connection
ws.on("error", (error) => {
this.handleConnectionError(error, clientInfo, ws);
});
} catch (error) {
this.handleConnectionError(error, clientInfo, ws);
}
}
private handleConnectionError(error: unknown, clientInfo: Record<string, any>, ws: WS) {
// Convert to AppError if needed
const appError = Errors.convertError(error instanceof Error ? error : new Error(String(error)), {
context: {
...clientInfo,
component: "WebSocketConnection",
},
});
// Log at appropriate level based on error category
if (appError.category === ErrorCategory.OPERATIONAL) {
logger.info(`WebSocket operational error: ${appError.message}`, {
error: appError,
clientInfo,
});
} else {
logger.error(`WebSocket error: ${appError.message}`, {
error: appError,
clientInfo,
stack: appError.stack,
});
}
// Alert if error threshold is reached
if (this.metrics.errors % 10 === 0) {
logger.warn(`High WebSocket error rate detected: ${this.metrics.errors} total errors`);
}
// Try to send error to client before closing
try {
if (ws.readyState === ws.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: appError.category === ErrorCategory.OPERATIONAL ? appError.message : "Internal server error",
})
);
}
} catch (sendError) {
// Ignore send errors at this point
}
// Close with informative message if connection is still open
if (ws.readyState === ws.OPEN) {
ws.close(
1011,
appError.category === ErrorCategory.OPERATIONAL
? `Error: ${appError.message}. Reconnect with exponential backoff.`
: "Internal server error. Please retry in a few moments."
);
}
}
getErrorMetrics() {
return {
errors: this.metrics.errors,
};
}
}
@@ -0,0 +1,128 @@
import type { Request, Response } from "express";
import { z } from "zod";
// helpers
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common";
// decorators
import { CatchErrors } from "@/lib/decorators";
// logger
import { logger } from "@plane/logger";
import { Controller, Post } from "@plane/decorators";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
description_html: z
.string()
.min(1, "HTML content cannot be empty")
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
variant: z.enum(["rich", "document"]),
});
@Controller("/convert-document")
export class DocumentController {
private metrics = {
conversions: 0,
errors: 0,
};
@Post("/")
@CatchErrors()
async convertDocument(req: Request, res: Response) {
const requestId = req.id || crypto.randomUUID();
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId,
};
try {
// Validate request body
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
const { description_html, variant } = validatedData;
// Log validated data
logger.info("Validated document conversion request", {
...clientInfo,
variant,
contentLength: description_html.length,
});
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Update metrics
this.metrics.conversions++;
// Log successful conversion
logger.info("Document conversion successful", {
...clientInfo,
variant,
outputLength: description_html.length,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
} catch (error) {
// Update error metrics
this.metrics.errors++;
let appError: AppError;
if (error instanceof z.ZodError) {
// Handle validation errors
appError = handleError(error, {
errorType: "unprocessable-entity",
message: "Invalid request data",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: {
...clientInfo,
validationErrors: error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
})),
},
});
} else {
// Handle other errors
appError = handleError(error, {
errorType: "internal",
message: "Internal server error",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: clientInfo,
});
}
// Log the error
logger.error("Document conversion failed", {
error: appError,
status: appError.status,
context: appError.context,
});
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
getMetrics() {
return {
conversions: this.metrics.conversions,
errors: this.metrics.errors,
};
}
}
@@ -0,0 +1,16 @@
import { CatchErrors } from "@/lib/decorators";
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
@Controller("/health")
export class HealthController {
@Get("/")
@CatchErrors()
async healthCheck(_req: Request, res: Response) {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "1.0.0",
});
}
}
+17
View File
@@ -0,0 +1,17 @@
// Export all controllers from this barrel file
import { CollaborationController } from "./collaboration.controller";
import { LiveDocumentController } from "./live-document.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
// Document management controllers
DOCUMENT: [DocumentController, LiveDocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
@@ -0,0 +1,143 @@
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
import { z } from "zod";
import * as Y from "yjs";
// Server agent
import { serverAgentManager } from "@/core/agents/server-agent";
// Helpers
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { env } from "@/env";
// Types
import { HocusPocusServerContext } from "@/core/types/common";
import { manualLogger } from "@/core/helpers/logger";
import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor";
// Schema for request validation
const getLiveDocumentValuesSchema = z.object({
documentId: z.string().min(1, "Document ID is required"),
variant: z.enum(["document"]),
workspaceSlug: z.string().min(1, "Workspace slug is required"),
});
@Controller("/live-document")
export class LiveDocumentController {
@Get("/")
async getLiveDocumentValues(req: Request, res: Response) {
const requestId = req.id || crypto.randomUUID();
try {
if (req.headers["live-server-secret-key"] !== env.LIVE_SERVER_SECRET_KEY) {
return res.status(401).json({
message: "Unauthorized access",
status: 401,
context: {
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
requestId,
},
});
}
const validatedData = getLiveDocumentValuesSchema.parse(req.query);
const { documentId, workspaceSlug } = validatedData;
const context: Partial<HocusPocusServerContext> = {
workspaceSlug,
};
try {
const { connection } = await serverAgentManager.getConnection(documentId, context);
// Define the document type
type DocumentData = {
description_binary: string;
description: object;
description_html: string;
name: string;
};
// Create a promise to wrap the setTimeout
const loadDocumentWithDelay = new Promise<DocumentData | null>((resolve) => {
let documentData: DocumentData;
connection.transact((doc) => {
const type = doc.getXmlFragment("default");
const contentDoc = type.doc;
if (!contentDoc) {
resolve(null);
return;
}
const yjsBinary = Y.encodeStateAsUpdate(contentDoc);
const { contentBinaryEncoded, contentJSON, contentHTML, titleHTML } =
getAllDocumentFormatsFromDocumentEditorBinaryData(yjsBinary);
documentData = {
description_binary: contentBinaryEncoded,
description: contentJSON,
description_html: contentHTML,
name: titleHTML,
};
resolve(documentData);
});
});
// Await the delayed document loading
const documentLoaded = await loadDocumentWithDelay;
await serverAgentManager.releaseConnection(documentId);
// Return the converted document
res.status(200).json(documentLoaded);
} catch (error) {
// Error during server agent connection or conversion
manualLogger.error(`Error processing document ${documentId}:`, error);
res.status(400).json({
loaded: false,
message: "Document not currently loaded in memory",
});
}
} catch (error) {
let appError: AppError;
if (error instanceof z.ZodError) {
// Handle validation errors
appError = handleError(error, {
errorType: "unprocessable-entity",
message: "Invalid request data",
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
extraContext: {
requestId,
validationErrors: error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
})),
},
});
} else {
// Handle other errors
appError = handleError(error, {
errorType: "internal",
message: "Internal server error",
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
extraContext: { requestId },
});
}
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./register";
@@ -0,0 +1,58 @@
import { PageService } from "@/core/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
const pageService = new PageService();
/**
* Fetches the binary description data for a project page
* Falls back to HTML transformation if binary is not available
*/
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;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await transformHTMLToBinary(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
};
/**
* Updates the description of a project page
*/
export const updatePageDescription = async (
params: URLSearchParams | undefined,
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;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
@@ -0,0 +1,3 @@
export * from "./handlers"
export * from "./transformers";
export * from "./project-page-handler";
@@ -0,0 +1,41 @@
import {
DocumentHandler,
DocumentFetchParams,
DocumentStoreParams,
HandlerDefinition,
} from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import { fetchPageDescriptionBinary, updatePageDescription } from "./handlers";
/**
* Handler for "project_page" document type
*/
export const projectPageHandler: DocumentHandler = {
/**
* Fetch project page description
*/
fetch: async ({ pageId, params, context }: DocumentFetchParams) => {
const { cookie } = context;
return await fetchPageDescriptionBinary(params, pageId, cookie);
},
/**
* Store project page description
*/
store: async ({ pageId, state, params, context }: DocumentStoreParams) => {
const { cookie } = context;
await updatePageDescription(params, pageId, state, cookie);
},
};
// Define the project page handler definition
export const projectPageHandlerDefinition: HandlerDefinition = {
selector: (context) => context.documentType === "project_page",
handler: projectPageHandler,
priority: 10, // Standard priority
};
// Register the handler directly from CE
export function registerProjectPageHandler() {
handlerFactory.register(projectPageHandlerDefinition);
}
@@ -0,0 +1,27 @@
import { PageService } from "@/core/services/page.service";
import { getBinaryDataFromHTMLString } from "@/core/helpers/page";
import { logger } from "@plane/logger";
const pageService = new PageService();
/**
* Transforms HTML description to binary format
*/
export const transformHTMLToBinary = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
) => {
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
+5
View File
@@ -0,0 +1,5 @@
import { registerProjectPageHandler } from "./project-page/project-page-handler";
export function initializeDocumentHandlers() {
registerProjectPageHandler();
}
-14
View File
@@ -1,14 +0,0 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { documentType } = args;
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
}
-15
View File
@@ -1,15 +0,0 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
updatedDescription: Uint8Array;
}
export const updateDocument = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Update failed: Invalid document type ${documentType} provided.`);
}
+28 -1
View File
@@ -1 +1,28 @@
export type TAdditionalDocumentTypes = {};
import { TPage } from "@plane/types";
import * as Y from "yjs";
export type TAdditionalDocumentTypes = null;
export interface ActionCondition {
name: string;
check: (page: TPage, isInDocument: boolean, context?: any) => boolean;
}
export interface DocumentAction {
name: string;
execute: (
xmlFragment: Y.XmlFragment,
page: TPage,
context: {
childNodesMap?: Map<string, Y.XmlElement>;
embeddedIDs?: Set<string>;
[key: string]: any;
}
) => void;
}
export interface ActionRule {
condition: string;
action: string;
priority: number;
}
+66
View File
@@ -0,0 +1,66 @@
import { env } from "@/env";
import compression from "compression";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express";
import { logger } from "@plane/logger";
import { logger as loggerMiddleware } from "@/core/helpers/logger";
/**
* Configure server middleware
* @param app Express application
*/
export function configureServerMiddleware(app: express.Application): void {
// Security middleware
app.use(helmet());
// CORS configuration
configureCors(app);
// Compression middleware
app.use(
compression({
level: env.COMPRESSION_LEVEL,
threshold: env.COMPRESSION_THRESHOLD,
}) as unknown as express.RequestHandler
);
// Cookie parsing
app.use(cookieParser());
// Logging middleware
app.use(loggerMiddleware);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
}
/**
* Configure CORS
* @param app Express application
*/
function configureCors(app: express.Application): void {
const origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
for (const origin of origins) {
logger.info(`Adding CORS allowed origin: ${origin}`);
app.use(
cors({
origin,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
}
/**
* Server configuration
*/
export const serverConfig = {
port: env.PORT,
basePath: env.LIVE_BASE_PATH,
terminationTimeout: env.SHUTDOWN_TIMEOUT,
};
+439
View File
@@ -0,0 +1,439 @@
import * as Y from "yjs";
import { Response } from "express";
import { DirectConnection, Hocuspocus } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { manualLogger } from "@/core/helpers/logger";
import { TPage } from "@plane/types";
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentProcessor } from "@/plane-live/lib/document-processor";
import { getDocumentHandler } from "@/core/handlers/page-handlers";
/**
* Metadata for a stored connection
*/
interface ConnectionData {
connection: DirectConnection;
context: Partial<HocusPocusServerContext>;
createdAt: number;
lastUsed: number;
}
/**
* Connection statistics for a document
*/
interface ConnectionStats {
documentId: string;
createdAt: string;
lastUsed: string;
idleTime: string;
}
/**
* Overall statistics for the connection manager
*/
interface ManagerStats {
totalConnections: number;
connections: ConnectionStats[];
}
/**
* Error thrown when the ServerAgentManager is not properly initialized
*/
class ServerAgentManagerError extends Error {
constructor(message: string) {
super(message);
this.name = "ServerAgentManagerError";
}
}
/**
* Manages server-side connections (agents) to the Hocuspocus server
* Implements the Singleton pattern to ensure only one instance exists
*/
export class ServerAgentManager {
private static instance: ServerAgentManager;
private connections: Map<string, ConnectionData>;
public hocuspocusServer: Hocuspocus | null;
private cleanupInterval: ReturnType<typeof setTimeout> | null = null;
/**
* Private constructor to enforce singleton pattern
*/
private constructor() {
this.connections = new Map<string, ConnectionData>();
this.hocuspocusServer = null;
this.cleanupInterval = null;
}
/**
* Get the singleton instance of the ServerAgentManager
* @returns {ServerAgentManager} The singleton instance
*/
public static getInstance(): ServerAgentManager {
if (!ServerAgentManager.instance) {
ServerAgentManager.instance = new ServerAgentManager();
}
return ServerAgentManager.instance;
}
/**
* Initialize the manager with a Hocuspocus server instance
* @param {Hocuspocus} server - The Hocuspocus server instance
* @returns {ServerAgentManager} The initialized manager instance for chaining
*/
public initialize(server: Hocuspocus): ServerAgentManager {
this.hocuspocusServer = server;
// Set up periodic cleanup of unused connections
this.startConnectionCleanup();
manualLogger.info("ServerAgentManager initialized");
return this;
}
/**
* Get or create a connection to a document
* @param {string} documentId - The document ID to connect to
* @param {HocusPocusServerContext} context - Additional context for the connection
* @returns {Promise<ConnectionData>} - The connection data object
* @throws {ServerAgentManagerError} If the manager is not initialized
*/
public async getConnection(documentId: string, context: Partial<HocusPocusServerContext>): Promise<ConnectionData> {
if (!this.hocuspocusServer) {
throw new ServerAgentManagerError("ServerAgentManager not initialized with a Hocuspocus server");
}
// Check if we already have a connection for this document
if (this.connections.has(documentId)) {
// manualLogger.info(`Reusing existing connection for document: ${documentId}`);
const connectionData = this.connections.get(documentId)!;
// Update last used timestamp
connectionData.lastUsed = Date.now();
return connectionData;
}
context.documentType = context.documentType === "sync_agent" ? "sync_agent" : "server_agent";
try {
// Create a new connection
const connection = await this.hocuspocusServer.openDirectConnection(documentId, {
documentType: context.documentType,
projectId: context.projectId,
workspaceSlug: context.workspaceSlug,
// triggerExecutionAfterLoad: context.triggerExecutionAfterLoad,
agentId: uuidv4(), // Unique ID for this server agent
});
// Store the connection with metadata
const connectionData: ConnectionData = {
connection,
context,
createdAt: Date.now(),
lastUsed: Date.now(),
};
this.connections.set(documentId, connectionData);
return connectionData;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Failed to create connection for document ${documentId}: ${errorMessage}`);
throw new ServerAgentManagerError(`Failed to create connection: ${errorMessage}`);
}
}
/**
* Execute a transaction on a document
* @param {string} documentId - The document ID
* @param {(doc: Y.Doc) => void | Promise<void>} transactionFn - The transaction function
* @param {HocusPocusServerContext} context - Additional context for the connection
* @returns {Promise<void>} - Promise that resolves when the transaction is complete
* @throws {ServerAgentManagerError} If the transaction fails
*/
public async executeTransaction(
documentId: string,
transactionFn: (_doc: Y.Doc) => void | Promise<void>,
context: Partial<HocusPocusServerContext>,
res?: Response
): Promise<boolean> {
let connectionData: ConnectionData | null = null;
try {
connectionData = await this.getConnection(documentId, context);
connectionData.lastUsed = Date.now();
connectionData.context = { ...connectionData.context };
// Execute the transaction
await connectionData.connection.transact(transactionFn);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Transaction error for document ${documentId}: ${errorMessage}`, {
documentId,
error: error instanceof Error ? error.stack : errorMessage,
context,
});
// Notify about transaction failure if needed
this.notifyTransactionFailure(documentId, errorMessage, res);
return false;
}
}
public notifySyncTrigger(
pageId: string,
context: HocusPocusServerContext,
options: {
componentType?: string;
targetNodeId?: string;
[key: string]: any;
} = {}
) {
if (!this.hocuspocusServer) return;
this.executeTransaction(
pageId,
async (doc) => {
const xmlFragment = doc.getXmlFragment("default");
let subPagesFromBackend: TPage[] | undefined = [];
if (!context.documentType) {
return;
}
const documentHandler = getDocumentHandler(context.documentType);
if (documentHandler && documentHandler.fetchSubPages) {
subPagesFromBackend = await documentHandler.fetchSubPages({
context,
pageId,
});
}
// // Process the document using our extensible system
DocumentProcessor.process(xmlFragment, subPagesFromBackend || [], options);
},
{
...context,
documentType: "sync_agent",
}
);
}
/**
* Notify clients about a successful transaction
* @private
* @param {string} documentId - The document ID
*/
public notifyTransactionSuccess(documentId: string, res?: Response): void {
if (!this.hocuspocusServer) return;
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return;
try {
manualLogger.info(`notified transaction success for ${documentId}:`);
// res.status(200).json({ success: true });
} catch (error) {
manualLogger.error(`Error notifying transaction success for ${documentId}:`, error);
}
}
/**
* Notify clients about a failed transaction
* @private
* @param {string} documentId - The document ID
* @param {string} errorMessage - The error message
*/
private notifyTransactionFailure(documentId: string, errorMessage: string, res?: Response): void {
if (!this.hocuspocusServer) return;
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return;
try {
// res.status(200).json({ success: true });
// document.broadcastStateless(
// JSON.stringify({
// type: "transaction_status",
// status: "error",
// message: errorMessage,
// timestamp: new Date().toISOString(),
// })
// );
} catch (error) {
manualLogger.error(`Error notifying transaction failure for ${documentId}:`, error);
}
}
/**
* Release a connection when it's no longer needed
* @param {string} documentId - The document ID
* @returns {Promise<void>}
*/
public async releaseConnection(documentId: string): Promise<void> {
if (!this.connections.has(documentId)) {
return;
}
const connectionData = this.connections.get(documentId)!;
try {
connectionData.connection.disconnect();
this.connections.delete(documentId);
manualLogger.info(`Released connection for document: ${documentId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Error releasing connection for document ${documentId}: ${errorMessage}`);
// We still want to remove it from our map even if disconnect fails
this.connections.delete(documentId);
}
}
/**
* Check if a document has any client connections (excluding our agent)
* @param {string} documentId - The document ID
* @returns {boolean} - True if there are client connections
*/
/**
* Check if a document has any client connections (excluding our agent)
* @param {string} documentId - The document ID
* @returns {boolean} - True if there are client connections
*/
public hasClientConnections(documentId: string): boolean {
if (!this.hocuspocusServer) return false;
// Get the document from the server
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return false;
return document.connections.size > 0;
}
/**
* Start periodic cleanup of unused connections
* @private
*/
private startConnectionCleanup(): void {
// Check every 5 minutes for unused connections
const CLEANUP_INTERVAL = 5 * 60 * 1000;
// Clear any existing interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.cleanupInterval = setInterval(() => {
this.cleanupUnusedConnections().catch((error) => {
manualLogger.error("Error during connection cleanup:", error);
});
}, CLEANUP_INTERVAL);
}
/**
* Clean up connections that are no longer needed
* @private
*/
private async cleanupUnusedConnections(): Promise<void> {
const documentsToCleanup: string[] = [];
for (const [documentId] of this.connections.entries()) {
// If no client connections exist (only our agent remains), schedule cleanup
if (!this.hasClientConnections(documentId)) {
documentsToCleanup.push(documentId);
}
}
// Release connections outside the loop to avoid modifying the map during iteration
for (const documentId of documentsToCleanup) {
manualLogger.info(`Cleaning up connection for document with no clients: ${documentId}`);
await this.releaseConnection(documentId);
}
if (documentsToCleanup.length > 0) {
manualLogger.info(`Cleaned up ${documentsToCleanup.length} connections with no clients`);
}
}
/**
* Check if a document has no client connections and release the agent if needed
* @param {string} documentId - The document ID to check
* @returns {Promise<boolean>} - True if the connection was released
*/
public async checkAndReleaseIfNoClients(documentId: string): Promise<boolean> {
if (!this.connections.has(documentId)) {
return false;
}
if (!this.hasClientConnections(documentId)) {
manualLogger.info(`No clients left for document ${documentId}, releasing agent connection`);
await this.releaseConnection(documentId);
return true;
}
return false;
}
/**
* Set up hooks on the HocusPocus server to trigger cleanup when clients disconnect
* @returns {ServerAgentManager} The manager instance for chaining
*/
public setupHocusPocusHooks(): ServerAgentManager {
if (!this.hocuspocusServer) {
throw new ServerAgentManagerError("ServerAgentManager not initialized with a Hocuspocus server");
}
// Add a hook to the onDisconnect event
this.hocuspocusServer.configure({
async onDisconnect({ documentName }) {
// Use a small delay to ensure the connection is fully closed before checking
setTimeout(async () => {
try {
await serverAgentManager.checkAndReleaseIfNoClients(documentName);
} catch (error) {
manualLogger.error(`Error checking client connections for ${documentName}:`, error);
}
}, 100); // Small delay to ensure connection state is updated
},
});
return this;
}
/**
* Get statistics about current connections
* @returns {ManagerStats} - Connection statistics
*/
public getStats(): ManagerStats {
return {
totalConnections: this.connections.size,
connections: Array.from(this.connections.entries()).map(([documentId, data]) => ({
documentId,
createdAt: new Date(data.createdAt).toISOString(),
lastUsed: new Date(data.lastUsed).toISOString(),
idleTime: Math.round((Date.now() - data.lastUsed) / 1000) + "s",
})),
};
}
/**
* Shutdown the manager and clean up all connections
* @returns {Promise<void>}
*/
public async shutdown(): Promise<void> {
// Clear the cleanup interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Release all connections
const documentIds = Array.from(this.connections.keys());
for (const documentId of documentIds) {
await this.releaseConnection(documentId);
}
manualLogger.info("ServerAgentManager shut down successfully");
}
}
// Create and export the singleton instance
export const serverAgentManager = ServerAgentManager.getInstance();
+4
View File
@@ -0,0 +1,4 @@
import { CONTROLLERS } from "@/plane-live/controllers";
// Helper to get all REST controllers
export const getAllControllers = () => [...CONTROLLERS.CORE, ...CONTROLLERS.DOCUMENT, ...CONTROLLERS.WEBSOCKET];
@@ -0,0 +1,233 @@
import { HocusPocusServerContext } from "@/core/types/common";
import { BasePageService } from "@/core/services/base-page.service";
import { logger } from "@plane/logger";
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
import { TPage } from "@plane/types";
import { DocumentHandler, HandlerDefinition } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/page-handlers/handler-factory";
/**
* Base class for page handlers with factory integration
*/
export abstract class BasePageHandler<TService extends BasePageService, TConfig extends Record<string, any>> {
/**
* The document type identifier
*/
protected abstract documentType: string;
constructor(protected service: TService) {}
/**
* Abstract method to get config from context
*/
protected abstract getConfig(context: HocusPocusServerContext): TConfig;
/**
* Fetches the binary description data for a page
*/
public async fetchPageDescriptionBinary({
pageId,
context,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<Uint8Array | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const response = await this.service.fetchDescriptionBinary({
pageId,
cookie,
config,
});
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await this.transformHTMLToBinary(config, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
}
/**
* Updates the description of a page
*/
public async updatePageDescription({
pageId,
state: updatedDescription,
title,
context,
}: {
pageId: string;
state: Uint8Array;
title: string;
context: HocusPocusServerContext;
}): Promise<void> {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
name: title,
};
await this.service.updateDescription({
config,
pageId,
data: payload,
cookie,
});
}
/**
* Fetches the title of a page
*/
public async fetchPageTitle({
context,
pageId,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<string | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
try {
const pageDetails = await this.service.fetchDetails({
config,
pageId,
cookie,
});
return pageDetails.name;
} catch (error) {
logger.error("Error while fetching page title", error);
throw error;
}
}
/**
* Updates the title of a page
*/
public async updatePageTitle({
context,
pageId,
title,
abortSignal,
}: {
pageId: string;
title: string;
abortSignal?: AbortSignal;
context: HocusPocusServerContext;
}): Promise<void> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const payload = {
name: title,
};
await this.service.updateTitle({
config,
pageId,
data: payload,
cookie,
abortSignal,
});
}
/**
* Fetches sub-page details
*/
public async fetchPageSubPageDetails({
context,
pageId,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<TPage[] | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
try {
const response = await this.service.fetchSubPageDetails({
config,
pageId,
cookie,
});
return response;
} catch (error) {
logger.error("Fetch error:", error);
throw error;
}
}
/**
* Transforms HTML to binary data
*/
private async transformHTMLToBinary(
config: TConfig,
pageId: string,
cookie: string
): Promise<Uint8Array | undefined> {
try {
const pageDetails = await this.service.fetchDetails({
config,
pageId,
cookie,
});
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
}
/**
* Creates the handler definition
*/
protected createHandlerDefinition(): HandlerDefinition {
const handler: DocumentHandler = {
fetch: this.fetchPageDescriptionBinary.bind(this),
store: this.updatePageDescription.bind(this),
fetchTitle: this.fetchPageTitle.bind(this),
updateTitle: this.updatePageTitle.bind(this),
fetchSubPages: this.fetchPageSubPageDetails.bind(this),
};
return {
selector: (context: Partial<HocusPocusServerContext>) => context.documentType === this.documentType,
handler,
priority: 10, // Standard priority
};
}
/**
* Registers the handler with the handler factory
*/
public register(): void {
const definition = this.createHandlerDefinition();
handlerFactory.register(definition);
}
}
@@ -0,0 +1,21 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { ServerAgentService } from "@/core/services/server-agent.service";
import { HocusPocusServerContext } from "@/core/types/common";
interface ServerAgentConfig {}
const serverAgentService = new ServerAgentService();
export class ServerAgentHandler extends BasePageHandler<ServerAgentService, ServerAgentConfig> {
protected documentType = "server_agent";
constructor() {
super(serverAgentService);
}
protected getConfig(context: HocusPocusServerContext): ServerAgentConfig {
return {};
}
}
export const serverAgentHandler = new ServerAgentHandler();
@@ -0,0 +1,22 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { HocusPocusServerContext } from "@/core/types/common";
import { SyncAgentService } from "@/core/services/sync-agent.service";
interface SyncAgentConfig {}
const syncAgentService = new SyncAgentService();
export class SyncAgentHandler extends BasePageHandler<SyncAgentService, SyncAgentConfig> {
protected documentType = "sync_agent";
constructor() {
super(syncAgentService);
}
protected getConfig(context: HocusPocusServerContext): SyncAgentConfig {
return {};
}
}
export const syncAgentHandler = new SyncAgentHandler();
+128
View File
@@ -0,0 +1,128 @@
import { Database } from "@hocuspocus/extension-database";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
import { storePayload } from "@hocuspocus/server";
import { extractTextFromHTML } from "./title-update/title-utils";
import { getDocumentHandler } from "../handlers/page-handlers";
export const createDatabaseExtension = () => {
return new Database({
fetch: handleFetch,
store: handleStore as (data: storePayload) => Promise<void>,
});
};
const handleFetch = async ({
context,
documentName,
}: {
context: HocusPocusServerContext;
documentName: string;
requestParameters: URLSearchParams;
}) => {
const { documentType } = context;
const pageId = documentName as TDocumentTypes;
let fetchedData = null;
fetchedData = await catchAsync(
async () => {
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "fetch",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(documentType);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
});
if (!fetchedData) {
handleError(null, {
errorType: "not-found",
message: `Failed to fetch document: ${pageId}`,
component: "database-extension",
operation: "fetch",
extraContext: { documentType, pageId },
});
}
return fetchedData;
},
{
params: { pageId, documentType: context.documentType },
extra: { operation: "fetch" },
}
)();
return fetchedData;
};
const handleStore = async ({
context,
state,
documentName,
document,
}: Partial<storePayload> & {
context: HocusPocusServerContext;
documentName: string;
}) => {
const pageId = documentName;
if (!context) {
console.error("Context is undefined in handleStore for document:", pageId);
return;
}
await catchAsync(
async () => {
if (!state) {
handleError(null, {
errorType: "bad-request",
message: "Loaded binary state is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
let title = "";
if (document) {
title = extractTextFromHTML(document?.getXmlFragment("title")?.toJSON());
}
const { documentType } = context as HocusPocusServerContext;
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(documentType);
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
title,
});
},
{
params: {
pageId,
documentType: context?.documentType || "unknown",
},
extra: { operation: "store" },
}
)();
};
+13 -128
View File
@@ -1,142 +1,27 @@
// Third-party libraries
import { Redis } from "ioredis";
// Hocuspocus extensions and core
import { Database } from "@hocuspocus/extension-database";
// hocuspocus extensions and core
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
fetchPageDescriptionBinary,
updatePageDescription,
} from "@/core/lib/page.js";
// plane live libraries
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
// types
import {
type HocusPocusServerContext,
type TDocumentTypes,
} from "@/core/types/common.js";
import { setupRedisExtension } from "@/core/extensions/setup-redis";
import { createDatabaseExtension } from "@/core/extensions/database";
import { logger } from "@plane/logger";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions: () => Promise<Extension[]> = async () => {
export const getExtensions = async (): Promise<Extension[]> => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
},
}),
new Database({
fetch: async ({ context, documentName: pageId, requestParameters }) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie,
);
} else {
fetchedData = await fetchDocument({
cookie,
documentType,
pageId,
params,
});
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
}
});
},
store: async ({
context,
state,
documentName: pageId,
requestParameters,
}) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
await updatePageDescription(params, pageId, state, cookie);
} else {
await updateDocument({
cookie,
documentType,
pageId,
params,
updatedDescription: state,
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
}
});
logger.info(message);
},
}),
createDatabaseExtension(),
new TitleSyncExtension(),
];
const redisUrl = getRedisUrl();
if (redisUrl) {
try {
const redisClient = new Redis(redisUrl);
await new Promise<void>((resolve, reject) => {
redisClient.on("error", (error: any) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH")
) {
redisClient.disconnect();
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
reject(error);
});
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
// Add Redis extensions if Redis is available
const redisExtensions = await setupRedisExtension();
if (redisExtensions) {
extensions.push(redisExtensions);
}
return extensions;
+17
View File
@@ -0,0 +1,17 @@
import { Redis } from "@hocuspocus/extension-redis";
import { OutgoingMessage } from "@hocuspocus/server";
export class CustomHocuspocusRedisExtension extends Redis {
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
return this.pub.publishBuffer(
// we're accessing the private method of the hocuspocus redis extension
this["pubKey"](documentName),
Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())])
);
}
}
+32
View File
@@ -0,0 +1,32 @@
// core helpers and utilities
import { RedisManager } from "@/core/lib/redis-manager";
import { CustomHocuspocusRedisExtension } from "./redis";
import { shutdownManager } from "../shutdown-manager";
/**
* Sets up the Redis extension for HocusPocus using the RedisManager singleton
* @returns Promise that resolves to a Redis extension array
*/
export const setupRedisExtension = async () => {
const redisManager = RedisManager.getInstance();
// Wait for Redis connection
const redisClient = await redisManager.connect();
if (redisClient) {
return new CustomHocuspocusRedisExtension({
redis: redisClient,
});
} else {
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
}
};
/**
* Helper to get the current Redis status
* Useful for health checks
*/
export const getRedisStatus = (): "connected" | "connecting" | "disconnected" | "not-configured" => {
const redisManager = RedisManager.getInstance();
return redisManager.getStatus();
};
+139
View File
@@ -0,0 +1,139 @@
// hocuspocus
import { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
// types
import { HocusPocusServerContext } from "@/core/types/common";
// editor extensions
import { TITLE_EDITOR_EXTENSIONS, createRealtimeEvent } from "@plane/editor";
// handlers
import { getDocumentHandler } from "@/core/handlers/page-handlers";
// helpers
import { generateTitleProsemirrorJson } from "@/core/helpers/generate-title-prosemirror-json";
import { extractTextFromHTML } from "./title-update/title-utils";
import { TitleUpdateManager } from "./title-update/title-update-manager";
import { broadcastMessageToPage } from "@/plane-live/lib/broadcast-message-to-page";
/**
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
instance!: Hocuspocus;
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
/**
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({ context, document }: { context: HocusPocusServerContext; document: Document }) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const documentHandler = getDocumentHandler(context.documentType);
const title = await documentHandler.fetchTitle?.({
context,
pageId: document.name,
});
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
"title",
// editor
TITLE_EDITOR_EXTENSIONS
);
document.merge(titleField);
}
} catch (error) {
console.error("Error in onLoadDocument: ", error);
}
}
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({
document,
documentName,
context,
instance,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
instance: Hocuspocus;
}) {
const documentHandler = getDocumentHandler(context.documentType);
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, context, documentHandler);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Set up observer for title field
const titleObserver = (events: Y.YEvent<any>[]) => {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON());
});
// Schedule an update with the manager
const manager = this.titleUpdateManagers.get(documentName);
// In your titleObserver
if (context.parentId) {
const event = createRealtimeEvent({
user_id: context.userId,
workspace_slug: context.workspaceSlug as string,
action: "title_updated",
page_id: documentName,
data: { title },
descendants_ids: [],
});
broadcastMessageToPage(instance, context.parentId, event);
}
if (manager) {
manager.scheduleUpdate(title);
}
};
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Force save title before unloading the document
*/
async beforeUnloadDocument({ documentName }: { documentName: string }) {
const updateManager = this.titleUpdateManagers.get(documentName);
if (updateManager) {
// Force immediate save and wait for it to complete
await updateManager.forceSave();
// Clean up the manager
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
async afterUnloadDocument({ documentName }: { documentName: string }) {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
this.titleObservers.delete(documentName);
}
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;
manager.cancel();
this.titleUpdateManagers.delete(documentName);
}
}
}
@@ -0,0 +1,275 @@
/**
* DebounceState - Tracks the state of a debounced function
*/
export interface DebounceState {
lastArgs: any[] | null;
timerId: ReturnType<typeof setTimeout> | null;
lastCallTime: number | undefined;
lastExecutionTime: number;
inProgress: boolean;
abortController: AbortController | null;
}
/**
* Creates a new DebounceState object
*/
export const createDebounceState = (): DebounceState => ({
lastArgs: null,
timerId: null,
lastCallTime: undefined,
lastExecutionTime: 0,
inProgress: false,
abortController: null,
});
/**
* DebounceOptions - Configuration options for debounce
*/
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
/**
* Enhanced debounce manager with abort support
* Manages the state and timing of debounced function calls
*/
export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
*/
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
* @param args The arguments to pass to the function
*/
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
// Always update the last arguments
this.state.lastArgs = args;
const time = Date.now();
this.state.lastCallTime = time;
// If an operation is in progress, just store the new args and start the timer
if (this.state.inProgress) {
// Always restart the timer for the new call, even if an operation is in progress
if (this.state.timerId) {
clearTimeout(this.state.timerId);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// If already scheduled, update the args and restart the timer
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// Start the timer for the trailing edge execution
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
/**
* Called when the timer expires
*/
private timerExpired(func: (...args: any[]) => Promise<void>): void {
const time = Date.now();
// Check if this timer expiration represents the end of the debounce period
if (this.shouldInvoke(time)) {
// Execute the function
this.executeFunction(func, time);
return;
}
// Otherwise restart the timer
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.remainingWait(time));
}
/**
* Execute the debounced function
*/
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
this.state.timerId = null;
this.state.lastExecutionTime = time;
// Execute the function asynchronously
this.performFunction(func).catch((error) => {
console.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
// Add the abort signal to the arguments if the function can use it
const execArgs = [...currentArgs];
execArgs.push(this.state.abortController.signal);
await func(...execArgs);
// Only clear lastArgs if they haven't been changed during this operation
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
this.state.lastArgs = null;
// Clear any timer as we've successfully processed the latest args
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
} else if (this.state.lastArgs) {
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
console.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} finally {
this.state.inProgress = false;
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
private async abortOngoingOperation(): Promise<void> {
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise((resolve) => setTimeout(resolve, 20));
// Double-check that state has been reset, force it if not
if (this.state.inProgress || this.state.abortController) {
this.state.inProgress = false;
this.state.abortController = null;
}
}
}
/**
* Determine if we should invoke the function now
*/
private shouldInvoke(time: number): boolean {
// Either this is the first call, or we've waited long enough since the last call
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
private remainingWait(time: number): number {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Perform the function immediately
if (this.state.lastArgs) {
await this.performFunction(func);
}
}
/**
* Cancel any pending operations without executing
*/
cancel(): void {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Abort any in-progress operation
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
this.state.inProgress = false;
this.state.abortController = null;
}
// Clear args
this.state.lastArgs = null;
}
/**
* Compare two arrays for equality
*/
private arraysEqual(a: any[], b: any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
@@ -0,0 +1,94 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { DebounceManager } from "./debounce";
import { HocusPocusServerContext } from "@/core/types/common";
import { logger } from "@plane/logger";
/**
* Manages title update operations for a single document
* Handles debouncing, aborting, and force saving title updates
*/
export class TitleUpdateManager {
private documentName: string;
private context: HocusPocusServerContext;
private documentHandler: DocumentHandler;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
/**
* Create a new TitleUpdateManager instance
*/
constructor(
documentName: string,
context: HocusPocusServerContext,
documentHandler: DocumentHandler,
wait: number = 5000
) {
this.documentName = documentName;
this.context = context;
this.documentHandler = documentHandler;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
wait,
logPrefix: `TitleManager[${documentName.substring(0, 8)}]`,
});
}
/**
* Schedule a debounced title update
*/
scheduleUpdate(title: string): void {
// Store the latest title
this.lastTitle = title;
// Schedule the update with the debounce manager
this.debounceManager.schedule(this.updateTitle.bind(this), title);
}
/**
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
if (!this.documentHandler.updateTitle) {
logger.warn(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await this.documentHandler.updateTitle({
context: this.context,
pageId: this.documentName,
title: title,
abortSignal: signal,
});
// Clear last title only if it matches what we just updated
if (this.lastTitle === title) {
this.lastTitle = null;
}
} catch (error) {
console.error(`Error updating title for ${this.documentName}:`, error);
}
}
/**
* Force save the current title immediately
*/
async forceSave(): Promise<void> {
// Ensure we have the current title
if (!this.lastTitle) {
return;
}
// Use the debounce manager to flush the operation
await this.debounceManager.flush(this.updateTitle.bind(this));
}
/**
* Cancel any pending updates
*/
cancel(): void {
this.debounceManager.cancel();
this.lastTitle = null;
}
}
@@ -0,0 +1,8 @@
/**
* Utility function to extract text from HTML content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -0,0 +1,29 @@
import { Request, Response } from "express";
import { manualLogger } from "@/core/helpers/logger";
import { TConvertDocumentRequestBody } from "@/core/types/common";
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
export const handleConvertDocument = (req: Request, res: Response) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
};
+1
View File
@@ -0,0 +1 @@
export { handleConvertDocument } from "./convert-document.handler";
@@ -0,0 +1,34 @@
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentHandler, HandlerDefinition } from "@/core/types/document-handler";
/**
* Class that manages handler selection based on multiple criteria
*/
export class DocumentHandlerFactory {
private handlers: HandlerDefinition[] = [];
/**
* Register a handler with its selection criteria
*/
register(definition: HandlerDefinition): void {
this.handlers.push(definition);
// Sort handlers by priority (highest first)
this.handlers.sort((a, b) => b.priority - a.priority);
}
/**
* Get the appropriate handler based on the provided context
*/
getHandler(context: Partial<HocusPocusServerContext>): DocumentHandler {
// Find the first handler whose selector returns true
const matchingHandler = this.handlers.find((h) => h.selector(context));
// console.log("matchingHandler:", matchingHandler);
// Return the matching handler or fall back to null/undefined
// (This will cause an error if no handlers match, which is good for debugging)
return matchingHandler?.handler as DocumentHandler;
}
}
// Create the singleton instance
export const handlerFactory = new DocumentHandlerFactory();
@@ -0,0 +1,26 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/page-handlers/handler-factory";
import { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common";
/**
* Get a document handler based on the provided context criteria
* @param documentType The primary document type
* @param additionalContext Optional additional context criteria
* @returns The appropriate document handler
*/
export function getDocumentHandler(
documentType: TDocumentTypes,
additionalContext?: Omit<HocusPocusServerContext, "documentType">
): DocumentHandler {
// Create a context object with all criteria
const context: Partial<HocusPocusServerContext> = {
documentType: documentType,
...additionalContext,
};
// Use the factory to get the appropriate handler
return handlerFactory.getHandler(context);
}
// Export the factory for direct access if needed
export { handlerFactory };
-44
View File
@@ -1,44 +0,0 @@
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};
-21
View File
@@ -1,21 +0,0 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
// Set the response status
res.status(err.status || 500);
// Send the response
res.json({
error: {
message:
process.env.NODE_ENV === "production"
? "An unexpected error occurred"
: err.message,
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
},
});
};
@@ -0,0 +1,276 @@
import { AppError, HttpStatusCode, ErrorCategory } from "./error-handler";
/**
* Map of error types to their corresponding factory functions
* This ensures that error types and their implementations stay in sync
*/
interface ErrorFactory {
statusCode: number;
category: ErrorCategory;
defaultMessage: string;
createError: (message?: string, context?: Record<string, any>) => AppError;
}
const ERROR_FACTORIES = {
"bad-request": {
statusCode: HttpStatusCode.BAD_REQUEST,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Bad Request",
createError: (message = "Bad Request", context?) =>
new AppError(message, HttpStatusCode.BAD_REQUEST, ErrorCategory.OPERATIONAL, context),
},
unauthorized: {
statusCode: HttpStatusCode.UNAUTHORIZED,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Unauthorized",
createError: (message = "Unauthorized", context?) =>
new AppError(message, HttpStatusCode.UNAUTHORIZED, ErrorCategory.OPERATIONAL, context),
},
forbidden: {
statusCode: HttpStatusCode.FORBIDDEN,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Forbidden",
createError: (message = "Forbidden", context?) =>
new AppError(message, HttpStatusCode.FORBIDDEN, ErrorCategory.OPERATIONAL, context),
},
"not-found": {
statusCode: HttpStatusCode.NOT_FOUND,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Resource not found",
createError: (message = "Resource not found", context?) =>
new AppError(message, HttpStatusCode.NOT_FOUND, ErrorCategory.OPERATIONAL, context),
},
conflict: {
statusCode: HttpStatusCode.CONFLICT,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Resource conflict",
createError: (message = "Resource conflict", context?) =>
new AppError(message, HttpStatusCode.CONFLICT, ErrorCategory.OPERATIONAL, context),
},
"unprocessable-entity": {
statusCode: HttpStatusCode.UNPROCESSABLE_ENTITY,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Unprocessable Entity",
createError: (message = "Unprocessable Entity", context?) =>
new AppError(message, HttpStatusCode.UNPROCESSABLE_ENTITY, ErrorCategory.OPERATIONAL, context),
},
"too-many-requests": {
statusCode: HttpStatusCode.TOO_MANY_REQUESTS,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Too many requests",
createError: (message = "Too many requests", context?) =>
new AppError(message, HttpStatusCode.TOO_MANY_REQUESTS, ErrorCategory.OPERATIONAL, context),
},
internal: {
statusCode: HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory.PROGRAMMING,
defaultMessage: "Internal Server Error",
createError: (message = "Internal Server Error", context?) =>
new AppError(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.PROGRAMMING, context),
},
"service-unavailable": {
statusCode: HttpStatusCode.SERVICE_UNAVAILABLE,
category: ErrorCategory.SYSTEM,
defaultMessage: "Service Unavailable",
createError: (message = "Service Unavailable", context?) =>
new AppError(message, HttpStatusCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM, context),
},
fatal: {
statusCode: HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory.FATAL,
defaultMessage: "Fatal Error",
createError: (message = "Fatal Error", context?) =>
new AppError(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.FATAL, context),
},
} satisfies Record<string, ErrorFactory>;
// Create the type from the keys of the error factories map
export type ErrorType = keyof typeof ERROR_FACTORIES;
// -------------------------------------------------------------------------
// Primary public API - Recommended for most use cases
// -------------------------------------------------------------------------
/**
* Base options for handleError function
*/
type BaseErrorHandlerOptions = {
// Error classification options
errorType?: ErrorType;
message?: string;
// Context information
component: string;
operation: string;
extraContext?: Record<string, any>;
// Behavior options
rethrowIfAppError?: boolean;
};
/**
* Options for throwing variant of handleError - discriminated by throw: true
*/
export type ThrowingOptions = BaseErrorHandlerOptions & {
throw: true;
};
/**
* Options for non-throwing variant of handleError - default behavior
*/
export type NonThrowingOptions = BaseErrorHandlerOptions;
/**
* Unified error handler that encapsulates common error handling patterns
*
* @param error The error to handle
* @param options Configuration options with throw: true to throw the error instead of returning it
* @returns Never returns - always throws
* @example
* // Throwing version
* handleError(error, {
* errorType: 'not-found',
* component: 'user-service',
* operation: 'getUserById',
* throw: true
* });
*/
export function handleError(error: unknown, options: ThrowingOptions): never;
/**
* Unified error handler that encapsulates common error handling patterns
*
* @param error The error to handle
* @param options Configuration options (non-throwing by default)
* @returns The AppError instance
* @example
* // Non-throwing version (default)
* const appError = handleError(error, {
* errorType: 'not-found',
* component: 'user-service',
* operation: 'getUserById'
* });
* return { error: appError.output() };
*/
export function handleError(error: unknown, options: NonThrowingOptions): AppError;
/**
* Implementation of handleError that handles both throwing and non-throwing cases
*/
export function handleError(error: unknown, options: ThrowingOptions | NonThrowingOptions): AppError | never {
// Only throw if throw is explicitly true
const shouldThrow = (options as ThrowingOptions).throw === true;
// If the error is already an AppError and we want to rethrow it as is
if (options.rethrowIfAppError !== false && error instanceof AppError) {
if (shouldThrow) {
throw error;
}
return error;
}
// Format the error message
const errorMessage = options.message
? error instanceof Error
? `${options.message}: ${error.message}`
: error
? `${options.message}: ${String(error)}`
: options.message
: error instanceof Error
? error.message
: error
? String(error)
: "Unknown error occurred";
// Build context object
const context = {
component: options.component,
operation: options.operation,
originalError: error,
...(options.extraContext || {}),
};
// Create the appropriate error type using our factory map
const errorType = options.errorType || "internal";
const factory = ERROR_FACTORIES[errorType];
if (!factory) {
// If no factory found, default to internal error
return ERROR_FACTORIES.internal.createError(errorMessage, context);
}
// Create the error with the factory
const appError = factory.createError(errorMessage, context);
// If we should throw, do so now
if (shouldThrow) {
throw appError;
}
return appError;
}
/**
* Utility function to convert errors or enhance existing AppErrors
*/
export const convertError = (
error: Error,
options?: {
statusCode?: number;
message?: string;
category?: ErrorCategory;
context?: Record<string, any>;
}
): AppError => {
if (error instanceof AppError) {
// If it's already an AppError and no overrides, return as is
if (!options?.statusCode && !options?.message && !options?.category) {
return error;
}
// Create a new AppError with the original as context
return new AppError(
options?.message || error.message,
options?.statusCode || error.status,
options?.category || error.category,
{
...(error.context || {}),
...(options?.context || {}),
originalError: error,
}
);
}
// Determine the appropriate error type based on status code
let errorType: ErrorType = "internal";
if (options?.statusCode) {
// Find the error type that matches the status code
const entry = Object.entries(ERROR_FACTORIES).find(([_, factory]) => factory.statusCode === options.statusCode);
if (entry) {
errorType = entry[0] as ErrorType;
}
}
// Return a new AppError using the factory
return handleError(error, {
errorType: errorType,
message: options?.message,
component: options?.context?.component || "unknown",
operation: options?.context?.operation || "convert-error",
extraContext: options?.context,
});
};
/**
* Check if an error is an AppError
*/
export const isAppError = (err: any, statusCode?: number): boolean => {
return err instanceof AppError && (!statusCode || err.status === statusCode);
};
// Export only the public API
export default {
handleError,
convertError,
isAppError,
};
@@ -0,0 +1,384 @@
import { ErrorRequestHandler, Request, Response, NextFunction } from "express";
import { env } from "@/env";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
import { ErrorContext, reportError } from "./error-reporting";
import { manualLogger } from "../logger";
/**
* HTTP Status Codes
*/
export enum HttpStatusCode {
// 2xx Success
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NO_CONTENT = 204,
// 4xx Client Errors
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
CONFLICT = 409,
GONE = 410,
UNPROCESSABLE_ENTITY = 422,
TOO_MANY_REQUESTS = 429,
// 5xx Server Errors
INTERNAL_SERVER = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
}
/**
* Error categories to classify errors
*/
export enum ErrorCategory {
OPERATIONAL = "operational", // Expected errors that are part of normal operation (e.g. validation failures)
PROGRAMMING = "programming", // Unexpected errors that indicate bugs (e.g. null references)
SYSTEM = "system", // System errors (e.g. out of memory, connection failures)
FATAL = "fatal", // Severe errors that should crash the app (e.g. unrecoverable state)
}
/**
* Base Application Error Class
* All custom errors extend this class
*/
export class AppError extends Error {
readonly status: number;
readonly category: ErrorCategory;
readonly context?: Record<string, any>;
readonly isOperational: boolean; // Kept for backward compatibility
constructor(
message: string,
status: number = HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory = ErrorCategory.PROGRAMMING,
context?: Record<string, any>
) {
super(message);
// Set error properties
this.name = this.constructor.name;
this.status = status;
this.category = category;
this.isOperational = category === ErrorCategory.OPERATIONAL;
this.context = context;
// Capture stack trace, excluding the constructor call from the stack
Error.captureStackTrace(this, this.constructor);
// Automatically report the error (unless it's being constructed by the error utilities)
if (!context?.skipReporting) {
this.report();
}
}
/**
* Creates a formatted representation of the error
*/
output() {
return {
statusCode: this.status,
payload: {
statusCode: this.status,
error: this.getErrorName(),
message: this.message,
category: this.category,
},
headers: {},
};
}
/**
* Gets a descriptive name for the error based on status code
*/
private getErrorName(): string {
const statusCodes: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
410: "Gone",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
return statusCodes[this.status] || "Unknown Error";
}
/**
* Reports the error to logging and monitoring systems
*/
private report(): void {
// Different logging based on error category
if (this.category === ErrorCategory.OPERATIONAL) {
manualLogger.error(`Operational error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
context: this.context,
});
} else if (this.category === ErrorCategory.FATAL) {
manualLogger.error(`FATAL error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
stack: this.stack,
context: this.context,
});
} else {
manualLogger.error(`${this.category} error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
stack: this.stack,
context: this.context,
});
}
}
}
export class FatalError extends AppError {
constructor(message: string, context?: Record<string, any>) {
super(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.FATAL, context);
}
}
/**
* Main Express error handler middleware
*/
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
// Already sent response, let default Express error handler deal with it
if (res.headersSent) {
return next(err);
}
// Convert to AppError if it's not already one
const error = handleError(err, {
component: "express",
operation: "error-handler",
extraContext: {
originalError: err,
url: req.originalUrl,
method: req.method,
},
});
// Normalize status code
const statusCode = error.status;
// Set the response status
res.status(statusCode);
// Set any custom headers if provided in the error object
if (err.headers && typeof err.headers === "object") {
Object.entries(err.headers).forEach(([key, value]) => {
res.set(key, value as string);
});
}
// Prepare error response
const errorResponse: {
error: {
message: string;
status: number;
stack?: string;
};
} = {
error: {
message:
error.category === ErrorCategory.OPERATIONAL || env.NODE_ENV !== "production"
? error.message
: "An unexpected error occurred",
status: statusCode,
},
};
// Add stack trace in non-production environments
if (env.NODE_ENV !== "production") {
errorResponse.error.stack = error.stack;
}
// Send the response
res.json(errorResponse);
// For fatal errors, log but NEVER terminate the app
if (error.category === ErrorCategory.FATAL) {
logger.error(`FATAL ERROR OCCURRED BUT APP WILL CONTINUE RUNNING: ${error.message}`);
}
};
export const asyncHandler = (fn: Function) => {
return (req: any, res: any, next: any) => {
Promise.resolve(fn(req, res, next)).catch((error) => {
// Convert to AppError if needed and pass to Express error middleware
const appError = handleError(error, {
errorType: "internal",
component: "express",
operation: "route-handler",
extraContext: {
url: req.originalUrl,
method: req.method,
body: req.body,
query: req.query,
params: req.params,
},
});
next(appError);
});
};
};
export interface CatchAsyncOptions<T, E = Error> {
/** Default value to return in case of error, null by default */
defaultValue?: T | null;
/** Whether to report non-AppErrors automatically */
reportErrors?: boolean;
/** Whether to rethrow the error after handling it */
rethrow?: boolean;
/** Custom error transformer function */
transformError?: (error: unknown) => E;
/** Custom error handler function that runs before standard handling */
onError?: (error: unknown) => void | Promise<void>;
/** Custom handler for specific error types */
errorHandlers?: {
[key: string]: (error: any) => T | null | Promise<T>;
};
}
export const catchAsync = <T, E = Error>(
fn: () => Promise<T>,
context?: ErrorContext,
options: CatchAsyncOptions<T, E> = {}
): (() => Promise<T | null>) => {
const { defaultValue = null, onError, rethrow = false } = options;
return async () => {
try {
return await fn();
} catch (error) {
// Apply custom error handler if provided
if (onError) {
await Promise.resolve(onError(error));
}
reportError(error, context);
if (error instanceof AppError) {
error.context;
}
if (rethrow) {
// Use handleError to ensure consistent error handling when rethrowing
handleError(error, {
component: context?.extra?.component || "unknown",
operation: context?.extra?.operation || "unknown",
extraContext: {
...context,
...(error instanceof AppError ? error.context : {}),
originalError: error,
},
throw: true,
});
}
return defaultValue;
}
};
};
/**
* Set up global error handlers for uncaught exceptions and unhandled rejections
* @param gracefulTerminationHandler Function to call for graceful termination
*/
export const setupGlobalErrorHandlers = (gracefulTerminationHandler: () => Promise<void>): void => {
// Handle promise rejections
process.on("unhandledRejection", (reason: unknown) => {
logger.error("Unhandled Promise Rejection", { reason });
// Convert to AppError and handle
const appError = handleError(reason, {
errorType: "internal",
message: reason instanceof Error ? reason.message : String(reason),
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
// Log the error but never terminate
logger.error(`Unhandled rejection caught and contained: ${appError.message}`);
});
// Handle exceptions
process.on("uncaughtException", (error: Error) => {
logger.error("Uncaught Exception", {
error: error.message,
stack: error.stack,
});
// Convert to AppError if needed
const appError = handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: {
source: "uncaughtException",
},
});
// Log the error but never terminate
logger.warn(`Uncaught exception contained: ${appError.message}`);
});
// Handle termination signals
process.on("SIGTERM", () => {
logger.info("SIGTERM received. Starting graceful termination...");
gracefulTerminationHandler();
});
process.on("SIGINT", () => {
logger.info("SIGINT received. Starting graceful termination...");
gracefulTerminationHandler();
});
};
/**
* Configure error handling middleware for the Express app
* @param app Express application instance
*/
export function configureErrorHandlers(app: any): void {
// Global error handling middleware
app.use(errorHandler);
// 404 handler must be last
app.use((_req: Request, _res: Response, next: NextFunction) => {
next(
handleError(null, {
errorType: "not-found",
message: "Resource not found",
component: "express",
operation: "route-handler",
extraContext: { path: _req.path },
throw: true,
})
);
});
}
@@ -0,0 +1,45 @@
import { AppError, ErrorCategory } from "./error-handler";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
export interface ErrorContext {
url?: string;
method?: string;
body?: any;
query?: any;
params?: any;
extra?: Record<string, any>;
}
/**
* Utility function to report errors that aren't instances of AppError
* AppError instances automatically report themselves on creation
* Only use this for external errors that don't use our error system
*/
export const reportError = (error: Error | unknown, context?: ErrorContext): void => {
if (error instanceof AppError) {
// if it's an app error, don't report it as it's already been reported
return;
}
logger.error(`External error: ${error instanceof Error ? error.stack || error.message : String(error)}`, {
error,
context,
});
};
export const handleFatalError = (error: Error | unknown, context?: ErrorContext): void => {
// Convert to fatal AppError
const fatalError = handleError(error, {
errorType: "fatal",
message: error instanceof Error ? error.message : String(error),
component: context?.extra?.component || "system",
operation: context?.extra?.operation || "fatal-error-handler",
extraContext: {
...context,
originalError: error,
},
});
process.emit("uncaughtException", fatalError);
};
@@ -0,0 +1,158 @@
import { handleError } from "./error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is required`,
component: 'validator',
operation: 'required',
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a string`,
component: 'validator',
operation: 'string',
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} cannot be empty`,
component: 'validator',
operation: 'notEmpty',
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a valid number`,
component: 'validator',
operation: 'number',
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a non-empty array`,
component: 'validator',
operation: 'nonEmptyArray',
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} has an invalid format`,
component: 'validator',
operation: 'match',
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be one of: ${allowedValues.join(", ")}`,
component: 'validator',
operation: 'oneOf',
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is invalid`,
component: 'validator',
operation: 'custom',
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
@@ -0,0 +1,21 @@
export const generateTitleProsemirrorJson = (text: string) => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};
+35 -23
View File
@@ -1,20 +1,34 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
import * as Y from "yjs";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
export const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
/**
* Extracts the text content from an HTML string
* @param html HTML string
* @returns text content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
titleHTML: string;
} => {
// encode binary description data
const base64Data = Buffer.from(description).toString("base64");
@@ -22,10 +36,11 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
).toJSON();
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
const title = yDoc.getXmlFragment("title");
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
@@ -33,27 +48,24 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
titleHTML,
};
}
};
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
export const getBinaryDataFromHTMLString = (
descriptionHTML: string
): {
contentBinary: Uint8Array;
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
);
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
);
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}
contentBinary: encodedData,
};
};
+126 -48
View File
@@ -1,73 +1,151 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { IncomingHttpHeaders } from "http";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
import { handleAuthentication } from "@/core/lib/authentication";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
import {
DocumentCollaborativeEvents,
TDocumentEventsServer,
} from "@plane/editor/lib";
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
import { EventToPayloadMap, TUserDetails, createRealtimeEvent } from "@plane/editor";
// types
import { type HocusPocusServerContext } from "@/core/types/common.js";
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
// error handling
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// server agent
import { serverAgentManager } from "./agents/server-agent";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
return Server.configure({
const server = Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
requestParameters,
context,
// user id used as token for authentication
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext;
requestParameters: URLSearchParams;
token: string;
}) => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
return catchAsync(
async () => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
// Extract cookie (fallback to request headers) and userId from token
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
console.error("Token parsing failed, using request headers:", error);
} finally {
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
}
if (!cookie || !userId) {
handleError(null, {
errorType: "unauthorized",
message: "Credentials not provided",
component: "hocuspocus",
operation: "authenticate",
extraContext: { tokenProvided: !!token },
throw: true,
});
}
// set cookie in context, so it can be used throughout the ws connection
(context as HocusPocusServerContext).cookie = cookie;
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
context.cookie = cookie ?? requestParameters.get("cookie") ?? "";
context.userId = userId;
context.workspaceSlug = requestParameters.get("workspaceSlug")?.toString() ?? "";
context.parentId = requestParameters.get("parentPageId")?.toString() ?? undefined;
context.projectId = requestParameters.get("projectId")?.toString() ?? "";
context.teamspaceId = requestParameters.get("teamspaceId")?.toString() ?? "";
try {
await handleAuthentication({
cookie,
userId,
});
} catch (error) {
throw Error("Authentication unsuccessful!");
}
return await handleAuthentication({
cookie: context.cookie,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
});
},
{ extra: { operation: "authenticate" } },
{ rethrow: true }
)();
},
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response =
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
onStateless: async ({ payload, document, connection }) => {
return catchAsync(
async () => {
const payloadStr = payload as string;
// Function to safely parse JSON without throwing exceptions
const safeJsonParse = (str: string) => {
try {
return { success: true, data: JSON.parse(str) };
} catch (e) {
return { success: false, error: e };
}
};
// First check if this is a known document event
const documentEvent = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client;
if (documentEvent) {
const eventType = documentEvent as keyof EventToPayloadMap;
let eventData: Partial<EventToPayloadMap[typeof eventType]> = {
user_id: connection.context.userId,
};
if (eventType === "archived") {
eventData = {
...eventData,
archived_at: new Date().toISOString(),
};
}
const realtimeEvent = createRealtimeEvent({
action: eventType,
page_id: document.name,
descendants_ids: [],
data: eventData as EventToPayloadMap[typeof eventType],
workspace_slug: connection.context.workspaceSlug || "",
user_id: connection.context.userId || "",
});
// Broadcast the event
document.broadcastStateless(JSON.stringify(realtimeEvent));
return;
}
// If not a document event, try to parse as JSON
const parseResult = safeJsonParse(payloadStr);
if (parseResult.success && parseResult.data && typeof parseResult.data === "object") {
const parsedPayload = parseResult.data as {
workspaceSlug?: string;
projectId?: string;
action?: string;
};
// Handle synced action
if (parsedPayload.action === "synced" && parsedPayload.workspaceSlug) {
serverAgentManager.notifySyncTrigger(document.name, connection.context);
return;
}
}
},
{ extra: { operation: "stateless", payload } }
)();
},
extensions,
extensions: [...extensions],
debounce: 10000,
});
return server;
};
+26 -7
View File
@@ -1,27 +1,46 @@
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
import { UserService } from "@/core/services/user.service";
import { handleError } from "@/core/helpers/error-handling/error-factory";
const userService = new UserService();
type Props = {
cookie: string;
userId: string;
workspaceSlug: string;
};
export const handleAuthentication = async (props: Props) => {
const { cookie, userId } = props;
const { cookie, userId, workspaceSlug } = props;
// fetch current user info
let response;
try {
response = await userService.currentUser(cookie);
} catch (error) {
manualLogger.error("Failed to fetch current user:", error);
throw error;
handleError(error, {
errorType: "unauthorized",
message: "Failed to authenticate user",
component: "authentication",
operation: "fetch-current-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
}
if (response.id !== userId) {
throw Error("Authentication failed: Token doesn't match the current user.");
handleError(null, {
errorType: "unauthorized",
message: "Authentication failed: Token doesn't match the current user.",
component: "authentication",
operation: "validate-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
}
return {
-112
View File
@@ -1,112 +0,0 @@
// helpers
import {
getAllDocumentFormatsFromBinaryData,
getBinaryDataFromHTMLString,
} from "@/core/helpers/page.js";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
const pageService = new PageService();
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;
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
cookie,
);
} catch (error) {
manualLogger.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,
);
const { contentBinary } = getBinaryDataFromHTMLString(
pageDetails.description_html ?? "<p></p>",
);
return contentBinary;
} catch (error) {
manualLogger.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) {
manualLogger.error("Fetch error:", error);
throw error;
}
};
+135
View File
@@ -0,0 +1,135 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
import { getRedisUrl } from "@/core/lib/utils/redis-url";
import { shutdownManager } from "@/core/shutdown-manager";
interface RedisError extends Error {
code?: string;
}
export class RedisManager {
private static instance: RedisManager;
private client: Redis | null = null;
private hasEverConnected = false;
private readonly maxReconnectAttempts = 3;
// Private constructor to enforce singleton pattern
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
public getClient(): Redis | null {
return this.client;
}
public async connect(): Promise<Redis | null> {
const redisUrl = getRedisUrl();
if (!redisUrl) {
shutdownManager.shutdown("Redis URL is not set, shutting down", 1);
return null;
}
this.client = new Redis(redisUrl, {
retryStrategy: (times: number): number | null => {
if (!this.hasEverConnected) {
// If we've never connected successfully, don't retry
logger.warn(
"Initial Redis connection attempt failed. Continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
return null;
} else {
// Once connected at least once, try a few times before giving up
if (times > this.maxReconnectAttempts) {
logger.error(`Exceeded ${this.maxReconnectAttempts} Redis reconnect attempts. Shutting down the server.`);
shutdownManager.shutdown("Redis connection lost and could not be recovered", 1);
return null; // This will never be reached due to shutdown, but needed for type safety
}
logger.warn(`Redis connection lost. Attempting to reconnect (#${times}) in 1000 ms...`);
return 1000; // wait 1 second between attempts
}
},
});
// Set up event handlers
this.client.on("connect", () => {
logger.info("Redis: connecting...");
});
this.client.on("ready", () => {
if (!this.hasEverConnected) {
logger.info("Redis: initial connection established and ready ✅");
} else {
logger.info("Redis: reconnected and ready ✅");
}
this.hasEverConnected = true;
});
this.client.on("error", (error: RedisError) => {
const fatalErrorCodes = [
"ENOTFOUND",
"ECONNREFUSED",
"ECONNRESET",
"ETIMEDOUT",
"EHOSTUNREACH",
"EPIPE",
"WRONGPASS",
"NOAUTH",
];
const fatalMessages = ["WRONGPASS", "NOAUTH", "READONLY", "LOADING", "CLUSTERDOWN", "CONNECTION_BROKEN"];
if (
(error?.code && fatalErrorCodes.includes(error.code)) ||
fatalMessages.some((msg) => error.message.includes(msg))
) {
if (this.client) this.client.disconnect();
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
} else {
logger.warn("Non-fatal Redis error:", error);
}
});
this.client.on("close", () => {
logger.warn("Redis connection closed.");
});
this.client.on("reconnecting", (delay: number) => {
logger.info(`Redis: reconnecting in ${delay} ms...`);
});
// Wait for connection to be ready or fail
return new Promise<Redis | null>((resolve) => {
if (!this.client) {
resolve(null);
return;
}
this.client.once("ready", () => {
resolve(this.client);
});
this.client.once("error", () => {
// The retryStrategy will handle this, we just need to resolve with null
// if initial connection fails
if (!this.hasEverConnected) {
resolve(null);
}
});
});
}
public getStatus(): "connected" | "connecting" | "disconnected" | "not-configured" {
if (!this.client) return "not-configured";
const status = this.client.status;
if (status === "ready") return "connected";
if (status === "connect" || status === "reconnecting") return "connecting";
return "disconnected";
}
}
@@ -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
View File
@@ -1,78 +0,0 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/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;
});
}
}
@@ -0,0 +1,19 @@
// services
import { ProjectPageConfig } from "@/ce/document-types/project-page-handler";
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
/**
* Service for handling project page operations
*/
export class ProjectPageService extends BasePageService {
/**
* Gets the base URL path for project pages
*/
protected getBasePath<TConfig extends ProjectPageConfig>(params: IBasePageParams<TConfig>): string {
const { pageId, config } = params;
const { workspaceSlug, projectId } = config;
// Handle project pages
return `/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}`;
}
}
@@ -0,0 +1,14 @@
// services
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
export interface ServerAgentConfig {}
export class ServerAgentService extends BasePageService {
/**
* Gets the base URL path for workspace pages
*/
protected getBasePath<TConfig extends ServerAgentConfig>(params: IBasePageParams<TConfig>): string {
const { pageId } = params;
return `/api/pages/${pageId}`;
}
}
@@ -0,0 +1,20 @@
// services
import { BasePageService, IBasePageParams, IUpdateDescriptionParams } from "@/core/services/base-page.service";
export interface SyncAgentConfig {}
export class SyncAgentService extends BasePageService {
/**
* Gets the base URL path for workspace pages
*/
protected getBasePath<TConfig extends SyncAgentConfig>(params: IBasePageParams<TConfig>): string {
const { pageId } = params;
return `/api/pages/${pageId}`;
}
async updateDescription<TConfig extends SyncAgentConfig>(params: IUpdateDescriptionParams<TConfig>) {
// no op
// since we can't prevent hocuspocus from updating the description after a
// sync event, we need to manually override the method to not do anything
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
import { API_BASE_URL, APIService } from "@/core/services/api.service";
export class UserService extends APIService {
constructor() {
+74
View File
@@ -0,0 +1,74 @@
import { logger } from "@plane/logger";
import { Server as HttpServer } from "http";
import { handleError } from "./helpers/error-handling/error-factory";
/**
* Handles graceful shutdown and process signal management for the HTTP server.
*/
class ShutdownManager {
private httpServer: HttpServer | null = null;
/**
* Register the HTTP server instance to be shut down later.
*/
register({ httpServer }: { httpServer: HttpServer }) {
this.httpServer = httpServer;
}
/**
* Register process termination signal handlers.
*/
registerTerminationHandlers(): void {
const gracefulTermination = this.getGracefulTerminationHandler();
process.on("SIGTERM", gracefulTermination);
process.on("SIGINT", gracefulTermination);
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: { source: "uncaughtException" },
});
});
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled rejection:", reason);
handleError(reason, {
errorType: "internal",
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
});
}
/**
* Returns a handler that gracefully shuts down the HTTP server.
*/
private getGracefulTerminationHandler(): () => Promise<void> {
return async () => {
logger.info("Signal received, shutting down HTTP server");
await this.shutdown();
};
}
/**
* Gracefully shuts down the registered HTTP server.
*/
async shutdown(message?: string, exitCode = 0): Promise<void> {
logger.error(`Initiating graceful shutdown ${`${message ? `with message: ${message}` : ""}`}`);
if (this.httpServer) {
this.httpServer.closeAllConnections?.();
await new Promise<void>((resolve) => {
this.httpServer?.close(() => resolve());
});
}
process.exit(exitCode);
}
}
export const shutdownManager = new ShutdownManager();
+6 -1
View File
@@ -1,10 +1,15 @@
// types
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
import { TAdditionalDocumentTypes } from "@/plane-live/types/common";
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type HocusPocusServerContext = {
cookie: string;
projectId: string;
workspaceSlug: string;
documentType: TDocumentTypes;
userId: string;
agentId: string;
};
export type TConvertDocumentRequestBody = {
+62
View File
@@ -0,0 +1,62 @@
import { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common";
/**
* Parameters for document fetch operations
*/
export interface DocumentFetchParams {
context: HocusPocusServerContext;
pageId: string;
params: URLSearchParams;
}
/**
* Parameters for document store operations
*/
export interface DocumentStoreParams {
context: HocusPocusServerContext;
pageId: string;
state: any;
params: URLSearchParams;
}
/**
* Interface defining a document handler
*/
export interface DocumentHandler {
/**
* Fetch a document
*/
fetch: (params: DocumentFetchParams) => Promise<any>;
/**
* Store a document
*/
store: (params: DocumentStoreParams) => Promise<void>;
}
/**
* Handler context interface - extend this to add new criteria for handler selection
*/
export interface HandlerContext {
documentType?: TDocumentTypes;
agentId?: string;
}
/**
* Handler selector function type - determines if a handler should be used based on context
*/
export type HandlerSelector = (context: HandlerContext) => boolean;
/**
* Handler definition combining a selector and implementation
*/
export interface HandlerDefinition {
selector: HandlerSelector;
handler: DocumentHandler;
priority: number; // Higher number means higher priority
}
/**
* Type for a handler registration function
*/
export type RegisterHandler = (definition: HandlerDefinition) => void;
@@ -0,0 +1,27 @@
import { Hocuspocus } from "@hocuspocus/server";
import { BroadcastedEvent } from "@plane/editor";
import { CustomHocuspocusRedisExtension } from "@/core/extensions/redis";
import { ServerAgentManager } from "@/core/agents/server-agent";
export const broadcastMessageToPage = (
instance: Hocuspocus | ServerAgentManager,
documentName: string,
eventData: BroadcastedEvent
): boolean => {
const hocuspocusServer =
"hocuspocusServer" in instance ? (instance as ServerAgentManager).hocuspocusServer : instance;
if (!hocuspocusServer || !hocuspocusServer.documents) {
console.error("HocusPocus server not available or initialized");
return false;
}
const redisExtension = hocuspocusServer.configuration.extensions.find(
(ext) => ext instanceof CustomHocuspocusRedisExtension
) as CustomHocuspocusRedisExtension | undefined;
if (redisExtension) {
redisExtension.broadcastToDocument(documentName, eventData);
return true;
}
return false;
};
+115
View File
@@ -0,0 +1,115 @@
import * as Y from "yjs";
/**
* Recursively finds all XML elements in the tree that match the given criteria
*
* @param node The root node to start searching from (Y.XmlFragment or Y.XmlElement)
* @param nodeName The node name to match (e.g. "pageEmbedComponent")
* @param attributeName The attribute name to match
* @param attributeValue The attribute value to match or "*" for any value
* @returns An array of objects containing the matched node and its path information
*/
export function findAllElementsRecursive(
node: Y.XmlFragment | Y.XmlElement,
nodeName: string,
attributeName: string,
attributeValue: string,
path: string[] = []
): Array<{
node: Y.XmlElement;
parent: Y.XmlFragment | Y.XmlElement;
indexInParent: number;
path: string[];
}> {
const results: Array<{
node: Y.XmlElement;
parent: Y.XmlFragment | Y.XmlElement;
indexInParent: number;
path: string[];
}> = [];
const children = node.toArray();
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child instanceof Y.XmlElement) {
// Calculate the path to this node
const nodePath = [...path];
if (child.nodeName) {
nodePath.push(`${child.nodeName}[${i}]`);
}
// Check if the current element matches the criteria
if (child.nodeName === nodeName) {
const attrValue = child.getAttribute(attributeName);
// Match if the attribute value is the wildcard "*" or if it matches exactly
if (attributeValue === "*" || attrValue === attributeValue) {
results.push({
node: child,
parent: node,
indexInParent: i,
path: nodePath,
});
}
}
// Recursively search in this element's children
const nestedResults = findAllElementsRecursive(child, nodeName, attributeName, attributeValue, nodePath);
results.push(...nestedResults);
}
}
return results;
}
/**
* Get a string representation of the path to a node for logging purposes
*
* @param pathArray The path array from findAllElementsRecursive
* @returns A string representation of the path
*/
export function getPathString(pathArray: string[]): string {
if (pathArray.length === 0) {
return "root";
}
return pathArray.join(" > ");
}
/**
* Insert a new node after a specific target node in the XML tree
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
* @param newNode The new node to insert
*/
export function insertNodeAfter(
parent: Y.XmlFragment | Y.XmlElement,
targetIndex: number,
newNode: Y.XmlElement
): void {
parent.insert(targetIndex + 1, [newNode]);
}
/**
* Replace a node in the XML tree with a new node
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
* @param newNode The new node to replace the target node with
*/
export function replaceNode(parent: Y.XmlFragment | Y.XmlElement, targetIndex: number, newNode: Y.XmlElement): void {
parent.delete(targetIndex, 1);
parent.insert(targetIndex, [newNode]);
}
/**
* Delete a node from the XML tree
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
*/
export function deleteNode(parent: Y.XmlFragment | Y.XmlElement, targetIndex: number): void {
parent.delete(targetIndex, 1);
}
-1
View File
@@ -1 +0,0 @@
export * from "../../ce/lib/authentication.js"
-1
View File
@@ -1 +0,0 @@
export * from "../../ce/lib/fetch-document.js"
-1
View File
@@ -1 +0,0 @@
export * from "../../ce/lib/update-document.js"
-1
View File
@@ -1 +0,0 @@
export * from "../../ce/types/common.js"
+44
View File
@@ -0,0 +1,44 @@
import * as dotenv from "dotenv";
import { z } from "zod";
// Load environment variables from .env file
dotenv.config();
// Define environment schema with validation
const envSchema = z.object({
// Server configuration
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.string().default("3000").transform(Number),
LIVE_BASE_PATH: z.string().default("/live"),
// CORS configuration
CORS_ALLOWED_ORIGINS: z.string().default("*"),
// Compression options
COMPRESSION_LEVEL: z.string().default("6").transform(Number),
COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number),
// Hocuspocus server configuration
HOCUSPOCUS_URL: z.string().optional(),
HOCUSPOCUS_USERNAME: z.string().optional(),
HOCUSPOCUS_PASSWORD: z.string().optional(),
// Graceful termination timeout
SHUTDOWN_TIMEOUT: z.string().default("10000").transform(Number),
// Live server secret key
LIVE_SERVER_SECRET_KEY: z.string(),
});
// Validate the environment variables
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4));
process.exit(1);
}
return result.data;
}
// Export the validated environment
export const env = validateEnv();
+32
View File
@@ -0,0 +1,32 @@
import { Router } from "express";
import { registerControllers as registerRestControllers, registerWebSocketControllers } from "@plane/decorators";
import "reflect-metadata";
/**
* Register all controllers from the controllers array
* @param router Express router to register routes on
* @param controllers Array of controller classes to register
* @param dependencies Array of dependencies to pass to controllers
*/
export function registerControllers(router: Router, controllers: any[], dependencies: any[] = []): void {
controllers.forEach((Controller) => {
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketControllers(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestControllers(router, Controller);
}
});
}
+149 -115
View File
@@ -1,135 +1,169 @@
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import express from "express";
import helmet from "helmet";
// hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
import type { Application, Request, Router } from "express";
import expressWs from "express-ws";
import type * as ws from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import http from "http";
const app: any = express();
expressWs(app);
// Environment and configuration
import { serverConfig, configureServerMiddleware } from "./config/server-config";
app.set("port", process.env.PORT || 3000);
// Core functionality
import { getHocusPocusServer } from "@/core/hocuspocus-server";
import { shutdownManager } from "@/core/shutdown-manager";
// Security middleware
app.use(helmet());
import { registerControllers } from "./lib/controller.utils";
// Middleware for response compression
app.use(
compression({
level: 6,
threshold: 5 * 1000,
})
);
// Redis manager
import { RedisManager } from "@/core/lib/redis-manager";
// Logging middleware
app.use(logger);
// Logging
import { logger } from "@plane/logger";
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Error handling
import { configureErrorHandlers } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { getAllControllers } from "./core/controller-registry";
import { serverAgentManager } from "./core/agents/server-agent";
import { serverAgentHandler } from "./core/document-types/server-agent-handlers";
import { syncAgentHandler } from "./core/document-types/sync-agent-handlers";
import { initializeDocumentHandlers } from "./ce/document-types";
// cors middleware
app.use(cors());
// WebSocket router type definition
interface WebSocketRouter extends Router {
ws: (_path: string, _handler: (ws: ws.WebSocket, req: Request) => void) => void;
}
const router = express.Router();
/**
* Main server class for the application
*/
export class Server {
private readonly app: Application;
private readonly port: number;
private httpServer: http.Server | null = null;
private hocusPocusServer!: Hocuspocus;
private redisManager: RedisManager;
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
/**
* Creates an instance of the server class.
* @param port Optional port number, defaults to environment configuration
*/
constructor(port?: number) {
this.app = express();
this.port = port || serverConfig.port;
this.redisManager = RedisManager.getInstance();
router.get("/health", (_req, res) => {
res.status(200).json({ status: "OK" });
});
// Initialize express-ws after Express setup
expressWs(this.app as any);
router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
configureServerMiddleware(this.app);
}
});
router.post("/convert-document", (req, res) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
/**
* Get the Express application instance
* Useful for testing
*/
getApp(): Application {
return this.app;
}
/**
* Initialize the server with all required components
* @returns The server instance for chaining
*/
async initialize() {
try {
// Initialize core services
await this.initializeServices();
// Set up routes
await this.setupRoutes();
// Set up error handlers
logger.info("Setting up error handlers");
configureErrorHandlers(this.app);
return this;
} catch (error) {
logger.error("Failed to initialize server:", error);
// This will always throw (never returns) - TypeScript correctly infers this
handleError(error, {
errorType: "internal",
component: "server",
operation: "initialize",
throw: true,
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res) => {
res.status(404).send("Not Found");
});
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info("HocusPocus server WebSocket connections closed gracefully.");
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
/**
* Initialize core services
*/
private async initializeServices() {
logger.info("Initializing Redis connection...");
await this.redisManager.connect();
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Initialize the Hocuspocus server
this.hocusPocusServer = await getHocusPocusServer();
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Initialize the server agent manager with the Hocuspocus server
serverAgentManager.initialize(this.hocusPocusServer);
serverAgentHandler.register();
syncAgentHandler.register();
// initialize all document handlers
initializeDocumentHandlers();
}
/**
* Set up API routes and WebSocket endpoints
*/
private async setupRoutes() {
try {
const router = express.Router() as WebSocketRouter;
// Get all controller classes
const controllers = getAllControllers();
// Register controllers with our simplified approach
// Pass the hocuspocus server as a dependency to the controllers that need it
registerControllers(router, controllers, [this.hocusPocusServer]);
// Mount the router on the base path
this.app.use(serverConfig.basePath, router);
} catch (error) {
handleError(error, {
errorType: "internal",
component: "server",
operation: "setupRoutes",
throw: true,
});
}
}
/**
* Start the server
* @returns HTTP Server instance
*/
async start() {
try {
this.httpServer = this.app.listen(this.port, () => {
logger.info(`Plane Live server has started at port ${this.port}`);
});
if (this.httpServer) {
shutdownManager.register({ httpServer: this.httpServer });
shutdownManager.registerTerminationHandlers();
}
} catch (error) {
handleError(error, {
errorType: "service-unavailable",
component: "server",
operation: "start",
extraContext: { port: this.port },
throw: true,
});
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Server } from "./server";
import { env } from "./env";
import { logger } from "@plane/logger";
import { handleError } from "@/core/helpers/error-handling/error-factory";
/**
* The main entry point for the application
* Starts the server and handles any startup errors
*/
const startServer = async () => {
try {
// Log server startup details
logger.info(`Starting Plane Live server in ${env.NODE_ENV} environment`);
// Initialize and start the server
const server = await new Server().initialize();
await server.start();
logger.info(`Server running at base path: ${env.LIVE_BASE_PATH}`);
} catch (error) {
logger.error("Failed to start server:", error);
// Create an AppError but DON'T exit
handleError(error, {
errorType: "internal",
component: "startup",
operation: "startServer",
extraContext: { environment: env.NODE_ENV }
});
// Continue running even if startup had issues
logger.warn("Server encountered errors during startup but will continue running");
}
};
// Start the server
startServer();
+23 -7
View File
@@ -1,23 +1,39 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2015"],
"module": "ES2015",
"moduleResolution": "Bundler",
"lib": [
"ES2015"
],
"target": "ES2015",
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
},
"removeComments": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"include": [
"src/**/*.ts",
"tsup.config.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
}
+13 -9
View File
@@ -1,11 +1,15 @@
import { defineConfig, Options } from "tsup";
import { defineConfig } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs", "esm"],
export default defineConfig({
entry: ["src/start.ts"],
format: ["esm", "cjs"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
splitting: false,
sourcemap: true,
minify: false,
target: "node18",
outDir: "dist",
env: {
NODE_ENV: process.env.NODE_ENV || "development",
},
});
+1 -2
View File
@@ -33,8 +33,7 @@
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10",
"chokidar": "3.6.0",
"tar-fs": "3.0.9",
"prosemirror-view": "1.40.0"
"tar-fs": "3.0.9"
},
"packageManager": "yarn@1.22.22"
}
+20 -19
View File
@@ -39,31 +39,32 @@
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "^2.22.3",
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
"@tiptap/html": "^2.22.3",
"@tiptap/pm": "^2.22.3",
"@tiptap/react": "^2.22.3",
"@tiptap/starter-kit": "^2.22.3",
"@tiptap/suggestion": "^2.22.3",
"@tiptap/core": "2.10.4",
"@tiptap/extension-blockquote": "2.10.4",
"@tiptap/extension-character-count": "2.11.0",
"@tiptap/extension-collaboration": "2.11.0",
"@tiptap/extension-image": "2.11.0",
"@tiptap/extension-list-item": "2.11.0",
"@tiptap/extension-mention": "2.11.0",
"@tiptap/extension-placeholder": "2.11.0",
"@tiptap/extension-task-item": "2.11.0",
"@tiptap/extension-task-list": "2.11.0",
"@tiptap/extension-text-align": "2.11.0",
"@tiptap/extension-text-style": "2.11.0",
"@tiptap/extension-underline": "2.11.0",
"@tiptap/html": "2.11.0",
"@tiptap/pm": "2.11.0",
"@tiptap/react": "2.11.0",
"@tiptap/starter-kit": "2.11.0",
"@tiptap/suggestion": "2.11.0",
"class-variance-authority": "^0.7.0",
"highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3",
"linkifyjs": "^4.1.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.469.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-safari-ime-span": "^1.0.2",
"prosemirror-utils": "^1.2.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",
+1 -1
View File
@@ -2,7 +2,7 @@
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { type HeadingExtensionStorage } from "@/extensions";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions";
@@ -18,7 +18,6 @@ import {
HeadingFiveItem,
HeadingSixItem,
EditorMenuItem,
ToggleListItem,
} from "@/components/menus";
// types
import { TEditorCommands } from "@/types";
@@ -43,7 +42,6 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
BulletListItem(editor),
NumberedListItem(editor),
TodoListItem(editor),
ToggleListItem(editor),
QuoteItem(editor),
CodeItem(editor),
];
@@ -10,7 +10,6 @@ import {
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
@@ -24,7 +23,6 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
// local components
import { TextAlignmentSelector } from "./alignment-selector";
import { TEditorCommands } from "@/types";
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
@@ -33,19 +31,19 @@ export interface EditorStateType {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
strike: boolean;
left: boolean;
right: boolean;
center: boolean;
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
backgroundColor:
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
}
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
@@ -60,10 +58,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strikethrough: StrikeThroughItem(props.editor),
"text-align": TextAlignItem(props.editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
strike: StrikeThroughItem(props.editor),
textAlign: TextAlignItem(props.editor),
};
const editorState: EditorStateType = useEditorState({
@@ -73,10 +69,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
underline: formattingItems.underline.isActive(),
strikethrough: formattingItems.strikethrough.isActive(),
left: formattingItems["text-align"].isActive({ alignment: "left" }),
right: formattingItems["text-align"].isActive({ alignment: "right" }),
center: formattingItems["text-align"].isActive({ alignment: "center" }),
strike: formattingItems.strike.isActive(),
left: formattingItems.textAlign.isActive({ alignment: "left" }),
right: formattingItems.textAlign.isActive({ alignment: "right" }),
center: formattingItems.textAlign.isActive({ alignment: "center" }),
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
}),
@@ -84,7 +80,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
@@ -22,7 +22,6 @@ import {
MinusSquare,
Palette,
AlignCenter,
ListCollapse,
} from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
@@ -36,14 +35,13 @@ import {
toggleBackgroundColor,
toggleBlockquote,
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleFlatBulletList,
toggleFlatOrderedList,
toggleFlatTaskList,
toggleFlatToggleList,
toggleHeading,
toggleItalic,
toggleOrderedList,
toggleStrike,
toggleTaskList,
toggleTextColor,
toggleUnderline,
} from "@/helpers/editor-commands";
@@ -138,35 +136,27 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("list", { kind: "bullet" }),
command: () => toggleFlatBulletList(editor),
isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("list", { kind: "ordered" }),
command: () => toggleFlatOrderedList(editor),
isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor?.isActive("list", { kind: "task" }),
command: () => toggleFlatTaskList(editor),
isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
export const ToggleListItem = (editor: Editor): EditorMenuItem<"toggle-list"> => ({
key: "toggle-list",
name: "Toggle list",
isActive: () => editor?.isActive("list", { kind: "toggle" }),
command: () => toggleFlatToggleList(editor),
icon: ListCollapse,
});
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
key: "quote",
name: "Quote",
@@ -258,7 +248,6 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
StrikeThroughItem(editor),
BulletListItem(editor),
TodoListItem(editor),
ToggleListItem(editor),
CodeItem(editor),
NumberedListItem(editor),
QuoteItem(editor),
@@ -41,5 +41,4 @@ export enum CORE_EXTENSIONS {
UNDERLINE = "underline",
UTILITY = "utility",
WORK_ITEM_EMBED = "issue-embed-component",
LIST = "list",
}
@@ -10,14 +10,14 @@ import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
export type CustomCalloutNodeViewProps = NodeViewProps & {
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CustomCalloutBlock: React.FC<CustomCalloutNodeViewProps> = (props) => {
export const CustomCalloutBlock: React.FC<Props> = (props) => {
const { editor, node, updateAttributes } = props;
// states
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
@@ -1,6 +1,6 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
import { CustomCalloutBlock } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
@@ -63,8 +63,6 @@ export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
));
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -1,6 +1,6 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
import { CustomCalloutBlock } from "@/extensions";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
@@ -9,8 +9,6 @@ export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.exten
draggable: false,
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
));
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -1,419 +0,0 @@
import { Editor, Extension } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import { Fragment, Node } from "prosemirror-model";
import { NodeSelection, Plugin } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import * as pmView from "prosemirror-view";
import { EditorView } from "prosemirror-view";
function fragmentToExternalHTML(view: pmView.EditorView, selectedFragment: Fragment, editor: Editor) {
let isWithinBlockContent = false;
const isWithinTable = view.state.selection instanceof CellSelection;
if (!isWithinTable) {
// Checks whether block ancestry should be included when creating external
// HTML. If the selection is within a block content node, the block ancestry
// is excluded as we only care about the inline content.
const fragmentWithoutParents = view.state.doc.slice(
view.state.selection.from,
view.state.selection.to,
false
).content;
// __AUTO_GENERATED_PRINT_VAR_START__
console.log(
"fragmentToExternalHTML#if fragmentWithoutParents: ",
fragmentWithoutParents,
JSON.stringify(fragmentWithoutParents) === JSON.stringify(selectedFragment)
); // __AUTO_GENERATED_PRINT_VAR_END__
const children: Node[] = [];
for (let i = 0; i < fragmentWithoutParents.childCount; i++) {
children.push(fragmentWithoutParents.child(i));
}
isWithinBlockContent =
children.find((child) => {
// console.clear();
console.log("child name:", child.type.name);
console.log("child spec group:", child.type.spec.group);
console.log("child isInGroup block:", child.type.isInGroup("block"));
return child.type.isInGroup("block") || child.type.name === "block" || child.type.spec.group === "block";
}) === undefined;
console.log("isWithinBlockContent", isWithinBlockContent);
if (isWithinBlockContent) {
selectedFragment = fragmentWithoutParents;
}
}
let externalHTML: string;
const externalHTMLExporter = createExternalHTMLExporter(view.state.schema, editor);
// if (isWithinTable) {
// // if (selectedFragment.firstChild?.type.name === "table") {
// // // contentNodeToTableContent expects the fragment of the content of a table, not the table node itself
// // // but cellselection.content() returns the table node itself if all cells and columns are selected
// // selectedFragment = selectedFragment.firstChild.content;
// // }
// //
// // // first convert selection to blocknote-style table content, and then
// // // pass this to the exporter
// // const ic = contentNodeToTableContent(
// // selectedFragment as any,
// // editor.schema.inlineContentSchema,
// // editor.schema.styleSchema
// // );
// //
// // // Wrap in table to ensure correct parsing by spreadsheet applications
// // externalHTML = `<table>${externalHTMLExporter.exportInlineContent(ic as any, {})}</table>`;
// if (isWithinBlockContent) {
// // first convert selection to blocknote-style inline content, and then
// // pass this to the exporter
// const ic = contentNodeToInlineContent(
// selectedFragment as any,
// editor.schema.inlineContentSchema,
// editor.schema.styleSchema
// );
// externalHTML = externalHTMLExporter.exportInlineContent(ic, {});
// }
// } else {
// const blocks = fragmentToBlocks(selectedFragment, editor.schema);
// externalHTML = externalHTMLExporter.exportBlocks(blocks, {});
// }
// return externalHTML;
}
export function selectedFragmentToHTML(
view: EditorView,
editor: Editor
): {
clipboardHTML: string;
externalHTML: string;
markdown?: string;
} {
// Checks if a `blockContent` node is being copied and expands
// the selection to the parent `blockContainer` node. This is
// for the use-case in which only a block without content is
// selected, e.g. an image block.
if ("node" in view.state.selection && (view.state.selection.node as Node).type.spec.group === "blockContent") {
editor.view.dispatch(
editor.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1)))
);
}
// Uses default ProseMirror clipboard serialization.
const clipboardHTML: string = (pmView as any).__serializeForClipboard(view, view.state.selection.content()).dom
.innerHTML;
const selectedFragment = view.state.selection.content().content;
console.log("selectedFragment", selectedFragment);
const externalHTML = fragmentToExternalHTML(view, selectedFragment, editor);
// const markdown = cleanHTMLToMarkdown(externalHTML);
return { clipboardHTML, externalHTML };
}
const copyToClipboard = (editor: Editor, view: EditorView, event: ClipboardEvent) => {
// Stops the default browser copy behaviour.
event.preventDefault();
event.clipboardData!.clearData();
const { clipboardHTML, externalHTML } = selectedFragmentToHTML(view, editor);
// TODO: Writing to other MIME types not working in Safari for
// some reason.
event.clipboardData!.setData("blocknote/html", clipboardHTML);
event.clipboardData!.setData("text/html", externalHTML);
// event.clipboardData!.setData("text/plain", markdown);
};
export const createCopyToClipboardExtension = () =>
Extension.create({
name: "copyToClipboard",
addProseMirrorPlugins(this) {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("copyToClipboard"),
props: {
handleDOMEvents: {
copy(view, event) {
copyToClipboard(editor, view, event);
// Prevent default PM handler to be called
return true;
},
cut(view, event) {
copyToClipboard(editor, view, event);
if (view.editable) {
view.dispatch(view.state.tr.deleteSelection());
}
// Prevent default PM handler to be called
return true;
},
// This is for the use-case in which only a block without content
// is selected, e.g. an image block, and dragged (not using the
// drag handle).
// dragstart(view, event) {
// // Checks if a `NodeSelection` is active.
// if (!("node" in view.state.selection)) {
// return;
// }
//
// // Checks if a `blockContent` node is being dragged.
// if ((view.state.selection.node as Node).type.spec.group !== "blockContent") {
// return;
// }
//
// // Expands the selection to the parent `blockContainer` node.
// editor.dispatch(
// editor._tiptapEditor.state.tr.setSelection(
// new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1))
// )
// );
//
// // Stops the default browser drag start behaviour.
// event.preventDefault();
// event.dataTransfer!.clearData();
//
// const { clipboardHTML, externalHTML, markdown } = selectedFragmentToHTML(view, editor);
//
// // TODO: Writing to other MIME types not working in Safari for
// // some reason.
// event.dataTransfer!.setData("blocknote/html", clipboardHTML);
// event.dataTransfer!.setData("text/html", externalHTML);
// event.dataTransfer!.setData("text/plain", markdown);
//
// // Prevent default PM handler to be called
// return true;
// },
},
},
}),
];
},
});
export function contentNodeToInlineContent(contentNode: Node, inlineContentSchema: any, styleSchema: any) {
const content = [];
let currentContent;
// Most of the logic below is for handling links because in ProseMirror links are marks
// while in BlockNote links are a type of inline content
contentNode.content.forEach((node) => {
// hardBreak nodes do not have an InlineContent equivalent, instead we
// add a newline to the previous node.
if (node.type.name === "hardBreak") {
if (currentContent) {
// Current content exists.
if (isStyledTextInlineContent(currentContent)) {
// Current content is text.
currentContent.text += "\n";
} else if (isLinkInlineContent(currentContent)) {
// Current content is a link.
currentContent.content[currentContent.content.length - 1].text += "\n";
} else {
throw new Error("unexpected");
}
} else {
// Current content does not exist.
currentContent = {
type: "text",
text: "\n",
styles: {},
};
}
return;
}
if (node.type.name !== "link" && node.type.name !== "text" && inlineContentSchema[node.type.name]) {
if (currentContent) {
content.push(currentContent);
currentContent = undefined;
}
content.push(nodeToCustomInlineContent(node, inlineContentSchema, styleSchema));
return;
}
const styles = {};
let linkMark;
for (const mark of node.marks) {
if (mark.type.name === "link") {
linkMark = mark;
} else {
const config = styleSchema[mark.type.name];
if (!config) {
throw new Error(`style ${mark.type.name} not found in styleSchema`);
}
if (config.propSchema === "boolean") {
(styles as any)[config.type] = true;
} else if (config.propSchema === "string") {
(styles as any)[config.type] = mark.attrs.stringValue;
} else {
}
}
}
// Parsing links and text.
// Current content exists.
if (currentContent) {
// Current content is text.
if (isStyledTextInlineContent(currentContent)) {
if (!linkMark) {
// Node is text (same type as current content).
if (JSON.stringify(currentContent.styles) === JSON.stringify(styles)) {
// Styles are the same.
currentContent.text += node.textContent;
} else {
// Styles are different.
content.push(currentContent);
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
} else {
// Node is a link (different type to current content).
content.push(currentContent);
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
} else if (isLinkInlineContent(currentContent)) {
// Current content is a link.
if (linkMark) {
// Node is a link (same type as current content).
// Link URLs are the same.
if (currentContent.href === linkMark.attrs.href) {
// Styles are the same.
if (
JSON.stringify(currentContent.content[currentContent.content.length - 1].styles) ===
JSON.stringify(styles)
) {
currentContent.content[currentContent.content.length - 1].text += node.textContent;
} else {
// Styles are different.
currentContent.content.push({
type: "text",
text: node.textContent,
styles,
});
}
} else {
// Link URLs are different.
content.push(currentContent);
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
} else {
// Node is text (different type to current content).
content.push(currentContent);
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
} else {
// TODO
}
}
// Current content does not exist.
else {
// Node is text.
if (!linkMark) {
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
// Node is a link.
else {
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
}
});
if (currentContent) {
content.push(currentContent);
}
return content;
}
export function isLinkInlineContent(content: { type: string }): boolean {
return content.type === "link";
}
export function isStyledTextInlineContent(content: { type: string }): boolean {
return typeof content !== "string" && content.type === "text";
}
export function nodeToCustomInlineContent(node: Node, inlineContentSchema: any, styleSchema: any) {
if (node.type.name === "text" || node.type.name === "link") {
throw new Error("unexpected");
}
const props: any = {};
const icConfig = inlineContentSchema[node.type.name];
for (const [attr, value] of Object.entries(node.attrs)) {
if (!icConfig) {
throw Error("ic node is of an unrecognized type: " + node.type.name);
}
const propSchema = icConfig.propSchema;
if (attr in propSchema) {
props[attr] = value;
}
}
let content;
if (icConfig.content === "styled") {
content = contentNodeToInlineContent(node, inlineContentSchema, styleSchema) as any; // TODO: is this safe? could we have Links here that are undesired?
} else {
content = undefined;
}
const ic = {
type: node.type.name,
props,
content,
};
return ic;
}
@@ -1,89 +0,0 @@
import { Extension } from "@tiptap/core";
import { Fragment, Node } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const markdownSerializer = this.editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === "tableRow";
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
if (nodeSelect) {
return markdownSerializer.serialize(slice.content);
}
const processTableContent = (tableNode: Node | Fragment) => {
let result = "";
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
tableRowNode.content?.forEach?.((cell: Node) => {
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
result += cellContent + "\n";
});
});
return result;
};
if (isTableRow) {
const rowsCount = slice.content?.childCount || 0;
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
if (rowsCount === 1 || cellsCount === 1) {
return processTableContent(slice.content);
} else {
return markdownSerializer.serialize(slice.content);
}
}
const traverseToParentOfLeaf = (
node: Node | null,
parent: Fragment | Node,
depth: number
): Node | Fragment => {
let currentNode = node;
let currentParent = parent;
let currentDepth = depth;
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
if (currentNode.content?.childCount > 1) {
if (currentNode.content.firstChild?.type?.name === "listItem") {
return currentParent;
} else {
return currentNode.content;
}
}
currentParent = currentNode;
currentNode = currentNode.content?.firstChild || null;
currentDepth--;
}
return currentParent;
};
if (slice.content.childCount > 1) {
return markdownSerializer.serialize(slice.content);
} else {
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
let currentNode = targetNode;
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
currentNode = currentNode.firstChild;
}
if (currentNode instanceof Node && currentNode.isText) {
return currentNode.text;
}
return markdownSerializer.serialize(targetNode);
}
},
},
}),
];
},
});
@@ -1,4 +1,4 @@
import { Selection, TextSelection } from "@tiptap/pm/state";
import { Selection } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
@@ -14,169 +14,28 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
return ReactNodeViewRenderer(CodeBlockComponent);
},
//@ts-expect-error todo
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
if (editor.isActive("codeBlock")) {
return editor.commands.newlineInCode();
}
},
Tab: ({ editor }) => {
try {
const { state } = editor;
const { selection } = state;
const { $from, $to, empty } = selection;
const { $from, empty } = selection;
if ($from.parent.type !== this.type) {
if (!empty || $from.parent.type !== this.type) {
return false;
}
let tr = state.tr;
// Store initial selection positions
const initialFrom = selection.from;
const initialTo = selection.to;
let offset = 0;
// Handle selection case
if (!empty) {
// Find the start of the first line in selection
let startPos = $from.pos;
while (startPos > $from.start() && !/[\n\r]/.test(state.doc.textBetween(startPos - 1, startPos))) {
startPos--;
}
// Find the end of the last line in selection
let endPos = $to.pos;
while (endPos < $to.end() && !/[\n\r]/.test(state.doc.textBetween(endPos, endPos + 1))) {
endPos++;
}
// Get the text content between start and end
const selectedText = state.doc.textBetween(startPos, endPos);
const lines = selectedText.split("\n");
// Add tabs to each line
let currentOffset = 0;
lines.forEach((line, index) => {
const pos = startPos + currentOffset;
tr = tr.insertText("\t", pos, pos);
currentOffset += line.length + 1 + 1; // +1 for newline, +1 for the inserted tab
// Update the total offset for selection adjustment
if (pos < initialFrom) offset++;
});
// Restore selection with adjusted positions
const newSelection = TextSelection.create(tr.doc, initialFrom + offset, initialTo + offset);
tr = tr.setSelection(newSelection);
} else {
// Single line case
let lineStart = $from.pos;
while (lineStart > $from.start() && !/[\n\r]/.test(state.doc.textBetween(lineStart - 1, lineStart))) {
lineStart--;
}
tr = tr.insertText("\t", lineStart, lineStart);
// Adjust cursor position
const newSelection = TextSelection.create(tr.doc, initialFrom + 1, initialTo + 1);
tr = tr.setSelection(newSelection);
}
// 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;
}
},
"Shift-Tab": ({ editor }) => {
try {
const { state } = editor;
const { selection } = state;
const { $from, $to, empty } = selection;
if ($from.parent.type !== this.type) {
return false;
}
let tr = state.tr;
// Store initial selection positions
const initialFrom = selection.from;
const initialTo = selection.to;
let offset = 0;
// Handle selection case
if (!empty) {
// Find the start of the first line in selection
let startPos = $from.pos;
while (startPos > $from.start() && !/[\n\r]/.test(state.doc.textBetween(startPos - 1, startPos))) {
startPos--;
}
// Find the end of the last line in selection
let endPos = $to.pos;
while (endPos < $to.end() && !/[\n\r]/.test(state.doc.textBetween(endPos, endPos + 1))) {
endPos++;
}
// Get the text content between start and end
const selectedText = state.doc.textBetween(startPos, endPos);
const lines = selectedText.split("\n");
// Remove tabs from each line
let currentOffset = 0;
for (let i = 0; i < lines.length; i++) {
const pos = startPos + currentOffset;
const firstChar = state.doc.textBetween(pos, pos + 1);
if (firstChar === "\t") {
tr = tr.delete(pos, pos + 1);
if (pos < initialFrom) offset--;
currentOffset += lines[i].length; // Don't add 1 for the deleted tab
} else {
currentOffset += lines[i].length + 1; // +1 for newline
}
}
// Restore selection with adjusted positions
const newSelection = TextSelection.create(
tr.doc,
Math.max(initialFrom + offset, 0),
Math.max(initialTo + offset, 0)
);
tr = tr.setSelection(newSelection);
} else {
// Single line case
let lineStart = $from.pos;
while (lineStart > $from.start() && !/[\n\r]/.test(state.doc.textBetween(lineStart - 1, lineStart))) {
lineStart--;
}
const firstChar = state.doc.textBetween(lineStart, lineStart + 1);
if (firstChar === "\t") {
tr = tr.delete(lineStart, lineStart + 1);
// Adjust cursor position
const newSelection = TextSelection.create(
tr.doc,
Math.max(initialFrom - 1, lineStart),
Math.max(initialTo - 1, lineStart)
);
tr = tr.setSelection(newSelection);
}
}
editor.view.dispatch(tr);
return true;
} catch (error) {
console.error("Error handling Shift-Tab in CustomCodeBlockExtension:", error);
return false;
}
},
ArrowUp: ({ editor }) => {
try {
const { state } = editor;
@@ -1,3 +1,4 @@
import { Selection } from "@tiptap/pm/state";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
// components
@@ -6,7 +7,108 @@ import { CodeBlockLowlight } from "./code-block-lowlight";
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.configure({
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,
@@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
import { CustomCodeInlineExtension } from "./code-inline";
import { CustomColorExtension } from "./custom-color";
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
import { CustomLinkExtension } from "./custom-link";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionConfig } from "./image";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
@@ -72,8 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtensionConfig,
CustomImageExtensionConfig,
ImageExtensionWithoutProps.configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps,
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -1,42 +1,68 @@
import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// local imports
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
// extensions
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
type CustomImageBlockProps = CustomImageNodeViewProps & {
editorContainer: HTMLDivElement | null;
type Pixel = `${number}px`;
type PixelAttribute<TDefault> = Pixel | TDefault;
export type ImageAttributes = {
src: string | null;
width: PixelAttribute<"35%" | number>;
height: PixelAttribute<"auto" | number>;
aspectRatio: number | null;
id: string | null;
};
type Size = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
imageFromFileSystem: string | undefined;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
src: string | undefined;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
extension,
getPos,
imageFromFileSystem,
node,
selected,
setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
updateAttributes,
setEditorContainer,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states
const [size, setSize] = useState<TCustomImageSize>({
const [size, setSize] = useState<Size>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null,
@@ -51,7 +77,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
const updateAttributesSafely = useCallback(
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
@@ -88,7 +114,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize: TCustomImageSize = {
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
@@ -113,7 +139,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
// for real time resizing
useLayoutEffect(() => {
@@ -142,7 +168,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
}, [size, updateAttributesSafely]);
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
@@ -216,7 +242,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
onLoad={handleImageLoad}
onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
@@ -227,7 +253,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await extension.options.restoreImage?.(imgNodeSrc);
await editor?.commands.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
@@ -263,10 +289,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
}
image={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
height: size.height,
width: size.width,
}}
/>
)}
@@ -2,26 +2,25 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtension;
export type CustomBaseImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: NodeViewProps["node"] & {
attrs: TCustomImageAttributes;
attrs: ImageAttributes;
};
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
selected: boolean;
};
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
export const CustomImageNode = (props: CustomImageNodeProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
@@ -51,37 +50,41 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
}, [resolvedSrc]);
useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
return;
}
const getImageSource = async () => {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
// @ts-expect-error function not expected here, but will still work and don't remove await
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc);
setResolvedSrc(url as string);
};
getImageSource();
}, [imgNodeSrc, extension.options]);
}, [imgNodeSrc]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
src={resolvedSrc}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc}
{...props}
selected={selected}
updateAttributes={updateAttributes}
/>
) : (
<CustomImageUploader
editor={editor}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
{...props}
selected={selected}
updateAttributes={updateAttributes}
/>
)}
</div>
@@ -1,30 +1,28 @@
import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// helpers
import { EFileError } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
// local imports
import { getImageComponentImageFileMap } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
loadImageFromFileSystem: (file: string) => void;
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
failedToLoadImage: boolean;
setIsUploaded: (isUploaded: boolean) => void;
};
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
extension,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
@@ -73,13 +71,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[extension.options, imageEntityId]
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
[editor, imageEntityId]
);
const handleProgressStatus = useCallback(
@@ -96,6 +93,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
editorCommand: uploadImageEditorCommand,
handleProgressStatus,
loadFileFromFileSystem: loadImageFromFileSystem,
@@ -130,7 +128,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
}, [meta, uploadFile, imageComponentImageFileMap]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
@@ -165,7 +163,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
return "Add an image";
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
@@ -0,0 +1,4 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -1,14 +1,14 @@
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
type Props = {
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
height: string;
width: string;
aspectRatio: number;
};
isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void;
@@ -1,16 +1,16 @@
import { useState } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// local imports
// components
import { ImageFullScreenAction } from "./full-screen";
type Props = {
containerClassName?: string;
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
height: string;
width: string;
aspectRatio: number;
};
};
@@ -0,0 +1,180 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// helpers
import { isFileValid } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import { TFileHandler } from "@/types";
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
};
}
}
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export interface CustomImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
}
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export const CustomImageExtension = (props: TFileHandler) => {
const {
getAssetSrc,
upload,
restore: restoreImageFn,
validation: { maxFileSize },
} = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
uploadImage: (blockId, file) => async () => {
const fileUrl = await upload(blockId, file);
return fileUrl;
},
getImageSource: (path) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -1,47 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
};
}
}
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
group: "block",
atom: true,
addAttributes() {
const attributes = {
...this.parent?.(),
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
});
@@ -1,123 +0,0 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// helpers
import { isFileValid } from "@/helpers/file";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
export const CustomImageExtension = (props: Props) => {
const { fileHandler, isEditable } = props;
// derived values
const { getAssetSrc, restore: restoreImageFn } = fileHandler;
return CustomImageExtensionConfig.extend({
selectable: isEditable,
draggable: isEditable,
addOptions() {
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
return {
...this.parent?.(),
getImageSource: getAssetSrc,
restoreImage: restoreImageFn,
uploadImage: upload,
};
},
addStorage() {
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize: this.storage.maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
};
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomImageNodeView {...props} node={props.node as CustomImageNodeViewProps["node"]} />
));
},
});
};
@@ -0,0 +1,3 @@
export * from "./components";
export * from "./custom-image";
export * from "./read-only-custom-image";
@@ -0,0 +1,79 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TReadOnlyFileHandler } from "@/types";
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc, restore: restoreImageFn } = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
selectable: false,
group: "block",
atom: true,
draggable: false,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize: 0,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -1,51 +0,0 @@
import type { Node } from "@tiptap/core";
// types
import type { TFileHandler } from "@/types";
export enum ECustomImageAttributeNames {
ID = "id",
WIDTH = "width",
HEIGHT = "height",
ASPECT_RATIO = "aspectRatio",
SOURCE = "src",
}
export type Pixel = `${number}px`;
export type PixelAttribute<TDefault> = Pixel | TDefault;
export type TCustomImageSize = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null;
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
[ECustomImageAttributeNames.SOURCE]: string | null;
};
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
export type CustomImageExtensionOptions = {
getImageSource: TFileHandler["getAssetSrc"];
restoreImage: TFileHandler["restore"];
uploadImage?: TFileHandler["upload"];
};
export type CustomImageExtensionStorage = {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
};
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
@@ -1,33 +0,0 @@
import type { Editor } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.SOURCE]: null,
[ECustomImageAttributeNames.ID]: null,
[ECustomImageAttributeNames.WIDTH]: "35%",
[ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
};
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export const ensurePixelString = <TDefault>(
value: Pixel | TDefault | number | undefined | null,
defaultValue?: TDefault
) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
@@ -0,0 +1 @@
export * from "./list-keymap";
@@ -0,0 +1,377 @@
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
import { Node, NodeType } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection;
const nodeType = getNodeType(typeOrName, state.schema);
let currentNode: Node | null = null;
let currentDepth = $from.depth;
let currentPos = $from.pos;
let targetDepth: number | null = null;
while (currentDepth > 0 && targetDepth === null) {
currentNode = $from.node(currentDepth);
if (currentNode.type === nodeType) {
targetDepth = currentDepth;
} else {
currentDepth -= 1;
currentPos -= 1;
}
}
if (targetDepth === null) {
return null;
}
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
};
const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth > listItemPos.depth) {
return true;
}
return false;
};
const getNextListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
return depth;
};
const getPrevListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
let depth = 0;
const pos = listItemPos.$pos;
// Adjust the position to ensure we're within the list item, especially for edge cases
const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0));
// Traverse up the document structure from the adjusted position
for (let d = resolvedPos.depth; d > 0; d--) {
const node = resolvedPos.node(d);
if (
[CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes(
node.type.name as CORE_EXTENSIONS
)
) {
// Increment depth for each list ancestor found
depth++;
}
}
// Subtract 1 from the calculated depth to get the parent list's depth
// This adjustment is necessary because the depth calculation includes the current list
// By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist
depth = depth > 0 ? depth - 1 : 0;
// Double the depth value to get results as 2, 4, 6, 8, etc.
depth = depth * 2;
return depth;
};
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
// this is required to still handle the undo handling
if (editor.commands.undoInputRule()) {
return true;
}
// Check if a node range is selected, and if so, fall back to default backspace functionality
const { from, to } = editor.state.selection;
if (from !== to) {
// A range is selected, not just a cursor position; fall back to default behavior
return false; // Let the editor handle backspace by default
}
// if the current item is NOT inside a list item &
// the previous item is a list (orderedList or bulletList)
// move the cursor into the list and delete the current item
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
const { $anchor } = editor.state.selection;
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
const listDescendants: Array<{ node: Node; pos: number }> = [];
$listPos.node().descendants((node, pos) => {
if (node.type.name === name) {
listDescendants.push({ node, pos });
}
});
const lastItem = listDescendants.at(-1);
if (!lastItem) {
return false;
}
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
// Check if positions are within the valid range
const startPos = $anchor.start() - 1;
const endPos = $anchor.end() + 1;
if (startPos < 0 || endPos > editor.state.doc.content.size) {
return false; // Invalid position, abort operation
}
return editor.chain().cut({ from: startPos, to: endPos }, $lastItemPos.end()).joinForward().run();
}
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
// if the cursor is not at the start of a node
// do nothing and proceed
if (!isAtStartOfNode(editor.state)) {
return false;
}
// is the paragraph node inside of the current list item (maybe with a hard break)
const isParaSibling = isCurrentParagraphASibling(editor.state);
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
const listItemPos = findListItemPos(name, editor.state);
const nextListItemIsSibling = nextListIsSibling(name, editor.state);
if (!listItemPos) {
return false;
}
const currentNode = listItemPos.$pos.node(listItemPos.depth);
const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode);
if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) {
return false;
}
if (currentListItemHasSubList && isCurrentListItemSublist) {
editor.chain().liftListItem(name).run();
return editor.commands.joinItemBackward();
}
if (isCurrentListItemSublist && nextListItemIsSibling) {
return false;
}
if (isCurrentListItemSublist) {
return false;
}
if (currentListItemHasSubList) {
return false;
}
if (hasListItemBefore(name, editor.state)) {
return editor.chain().liftListItem(name).run();
}
if (!currentListItemHasSubList) {
return false;
}
// otherwise in the end, a backspace should
// always just lift the list item if
// joining / merging is not possible
return editor.chain().liftListItem(name).run();
};
export const handleDelete = (editor: Editor, name: string) => {
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
// if the cursor is not at the end of a node
// do nothing and proceed
if (!isAtEndOfNode(editor.state, name)) {
return false;
}
// check if the next node is a list with a deeper depth
if (nextListIsDeeper(name, editor.state)) {
return editor
.chain()
.focus(editor.state.selection.from + 4)
.lift(name)
.joinBackward()
.run();
}
if (nextListIsHigher(name, editor.state)) {
return editor.chain().joinForward().joinBackward().run();
}
return editor.commands.joinItemForward();
};
const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
const { $anchor } = editorState.selection;
const previousNodePos = Math.max(0, $anchor.pos - 2);
const previousNode = editorState.doc.resolve(previousNodePos).node();
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
return false;
}
return true;
};
const prevListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getPrevListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};
const nextListIsSibling = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth === listItemPos.depth) {
return true;
}
return false;
};
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};
const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
if (!node) {
return false;
}
const nodeType = getNodeType(typeOrName, state.schema);
let hasSubList = false;
node.descendants((child) => {
if (child.type === nodeType) {
hasSubList = true;
}
});
return hasSubList;
};
const isCurrentParagraphASibling = (state: EditorState): boolean => {
const { $from } = state.selection;
const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item.
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
// Ensure we're in a paragraph and the parent is a list item.
if (
currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH &&
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS)
) {
let paragraphNodesCount = 0;
listItemNode.forEach((child) => {
if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) {
paragraphNodesCount++;
}
});
// If there are more than one paragraph nodes, the current paragraph is a sibling.
return paragraphNodesCount > 1;
}
return false;
};
export function isCursorInSubList(editor: Editor) {
const { selection } = editor.state;
const { $from } = selection;
// Check if the current node is a list item
const listItem = editor.schema.nodes.listItem;
const taskItem = editor.schema.nodes.taskItem;
// Traverse up the document tree from the current position
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type === listItem || node.type === taskItem) {
// If the parent of the list item is also a list, it's a sub-list
const parent = $from.node(depth - 1);
if (
parent &&
(parent.type === editor.schema.nodes.bulletList ||
parent.type === editor.schema.nodes.orderedList ||
parent.type === editor.schema.nodes.taskList)
) {
return true;
}
}
}
return false;
}
const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection;
const $targetPos = state.doc.resolve($anchor.pos - 2);
if ($targetPos.index() === 0) {
return false;
}
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
return false;
}
return true;
};
@@ -0,0 +1,134 @@
import { Extension } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers";
export type ListKeymapOptions = {
listTypes: Array<{
itemName: string;
wrapperNames: string[];
}>;
};
export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
Extension.create<ListKeymapOptions>({
name: "listKeymap",
addOptions() {
return {
listTypes: [
{
itemName: "listItem",
wrapperNames: ["bulletList", "orderedList"],
},
{
itemName: "taskItem",
wrapperNames: ["taskList"],
},
],
};
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) {
return true;
} else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) {
return true;
}
return true;
}
// if tabIndex is set, we don't want to handle Tab key
if (tabIndex !== undefined && tabIndex !== null) {
return false;
}
return true;
},
"Shift-Tab": () => {
if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) {
return true;
} else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) {
return true;
}
// if tabIndex is set, we don't want to handle Tab key
if (tabIndex !== undefined && tabIndex !== null) {
return false;
}
return true;
},
Delete: ({ editor }) => {
try {
let handled = false;
this.options.listTypes.forEach(({ itemName }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleDelete(editor, itemName)) {
handled = true;
}
});
return handled;
} catch (e) {
console.log("Error in handling Delete:", e);
return false;
}
},
"Mod-Delete": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleDelete(editor, itemName)) {
handled = true;
}
});
return handled;
},
Backspace: ({ editor }) => {
try {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
} catch (e) {
console.log("Error in handling Backspace:", e);
return false;
}
},
"Mod-Backspace": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
},
};
},
});
@@ -1,476 +0,0 @@
import { Editor, Extension } from "@tiptap/core";
import { Plugin, EditorState, PluginKey, NodeSelection } from "@tiptap/pm/state";
import { dropPoint } from "@tiptap/pm/transform";
import { EditorView } from "@tiptap/pm/view";
interface DropCursorOptions {
/// The color of the cursor. Defaults to `black`. Use `false` to apply no color and rely only on class.
color?: string | false;
/// The precise width of the cursor in pixels. Defaults to 1.
width?: number;
/// A CSS class name to add to the cursor element.
class?: string;
}
export function dropCursor(options: DropCursorOptions = {}, tiptapEditorOptions: { editor: Editor }): Plugin {
const pluginKey = new PluginKey("dropCursor");
return new Plugin({
key: pluginKey,
state: {
init() {
return { dropPosByDropCursorPos: null };
},
apply(tr, state) {
// Get the new state from meta
const meta = tr.getMeta(pluginKey);
if (meta) {
return { dropPosByDropCursorPos: meta.dropPosByDropCursorPos };
}
return state;
},
},
view(editorView) {
return new DropCursorView(editorView, options, tiptapEditorOptions.editor, pluginKey);
},
props: {
handleDrop(view, event, slice, moved) {
const { isBetweenFlatLists, isHoveringOverListContent } =
rawIsBetweenFlatListsFn(event, tiptapEditorOptions.editor) || {};
const state = pluginKey.getState(view.state);
let dropPosByDropCursorPos = state?.dropPosByDropCursorPos;
if (isHoveringOverListContent) {
dropPosByDropCursorPos -= 1;
}
if (isBetweenFlatLists && dropPosByDropCursorPos) {
const tr = view.state.tr;
if (moved) {
// Get the size of content to be deleted
const selection = tr.selection;
const deleteSize = selection.to - selection.from;
// Adjust drop position if it's after the deletion point
if (dropPosByDropCursorPos > selection.from) {
dropPosByDropCursorPos -= deleteSize;
}
tr.deleteSelection();
}
// Insert the content
tr.insert(dropPosByDropCursorPos, slice.content);
// Create a NodeSelection on the newly inserted content
const $pos = tr.doc.resolve(dropPosByDropCursorPos);
const node = $pos.nodeAfter;
if (node) {
const nodeSelection = NodeSelection.create(tr.doc, dropPosByDropCursorPos);
tr.setSelection(nodeSelection);
}
view.dispatch(tr);
return true;
}
return false;
},
},
});
}
// Add disableDropCursor to NodeSpec
declare module "prosemirror-model" {
interface NodeSpec {
disableDropCursor?:
| boolean
| ((view: EditorView, pos: { pos: number; inside: number }, event: DragEvent) => boolean);
}
}
class DropCursorView {
private width: number;
private color: string | undefined;
private class: string | undefined;
private cursorPos: number | null = null;
private element: HTMLElement | null = null;
private timeout: ReturnType<typeof setTimeout> | null = null;
private handlers: { name: string; handler: (event: Event) => void }[];
private editor: Editor;
// Throttled version of our isBetweenFlatListsFn
private isBetweenFlatListsFn: (event: DragEvent) => ReturnType<typeof rawIsBetweenFlatListsFn>;
constructor(
private readonly editorView: EditorView,
options: DropCursorOptions,
editor: Editor,
private readonly pluginKey: PluginKey
) {
this.width = options.width ?? 1;
this.color = options.color === false ? undefined : options.color || `rgb(115, 115, 115)`;
this.class = options.class;
this.editor = editor;
// Create the throttled function and store for use in dragover
this.isBetweenFlatListsFn = createThrottledIsBetweenFlatListsFn(editor);
this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
const handler = (e: Event) => {
(this as any)[name](e);
};
editorView.dom.addEventListener(name, handler);
return { name, handler };
});
}
destroy() {
this.handlers.forEach(({ name, handler }) => this.editorView.dom.removeEventListener(name, handler));
}
update(editorView: EditorView, prevState: EditorState) {
if (this.cursorPos != null && prevState.doc != editorView.state.doc) {
if (this.cursorPos > editorView.state.doc.content.size) this.setCursor(null);
else this.updateOverlay();
}
}
setCursor(pos: number | null, isBetweenFlatLists?: boolean) {
this.cursorPos = pos;
if (pos == null) {
if (this.element?.parentNode) {
this.element.parentNode.removeChild(this.element);
}
this.element = null;
} else {
this.updateOverlay(isBetweenFlatLists);
}
}
updateOverlay(isBetweenFlatLists?: boolean) {
const isBetweenFlatList = isBetweenFlatLists ?? false;
const $pos = this.editorView.state.doc.resolve(this.cursorPos!);
const isBlock = !$pos.parent.inlineContent;
let rect: Partial<DOMRect> | undefined;
const editorDOM = this.editorView.dom;
const editorRect = editorDOM.getBoundingClientRect();
const scaleX = editorRect.width / editorDOM.offsetWidth;
const scaleY = editorRect.height / editorDOM.offsetHeight;
if (isBlock) {
const before = $pos.nodeBefore;
const after = $pos.nodeAfter;
if (before || after) {
const node = this.editorView.nodeDOM(this.cursorPos! - (before ? before.nodeSize : 0));
if (node) {
const nodeRect = (node as HTMLElement).getBoundingClientRect();
let top = before ? nodeRect.bottom : nodeRect.top;
if (before && after) {
top = (top + (this.editorView.nodeDOM(this.cursorPos!) as HTMLElement).getBoundingClientRect().top) / 2;
}
const halfWidth = (this.width / 2) * scaleY;
rect = {
left: nodeRect.left,
right: nodeRect.right,
top: top - halfWidth,
bottom: top + halfWidth,
};
}
}
}
if (!rect) {
const coords = this.editorView.coordsAtPos(this.cursorPos!);
const halfWidth = (this.width / 2) * scaleX;
rect = {
left: coords.left - halfWidth,
right: coords.left + halfWidth,
top: coords.top,
bottom: coords.bottom,
};
}
const parent = this.editorView.dom.offsetParent as HTMLElement;
if (!this.element) {
this.element = parent.appendChild(document.createElement("div"));
if (this.class) this.element.className = this.class;
this.element.style.cssText = "position: absolute; z-index: 50; pointer-events: none";
if (this.color) {
this.element.style.backgroundColor = this.color;
}
}
this.element.classList.toggle("prosemirror-dropcursor-block", isBlock);
this.element.classList.toggle("prosemirror-dropcursor-inline", !isBlock);
let parentLeft: number, parentTop: number;
if (!parent || (parent == document.body && getComputedStyle(parent).position == "static")) {
parentLeft = -window.scrollX;
parentTop = -window.scrollY;
} else {
const parentRect = parent.getBoundingClientRect();
const parentScaleX = parentRect.width / parent.offsetWidth;
const parentScaleY = parentRect.height / parent.offsetHeight;
parentLeft = parentRect.left - parent.scrollLeft * parentScaleX;
parentTop = parentRect.top - parent.scrollTop * parentScaleY;
}
// Adjust left if we're between flat lists
const finalLeft = (rect.left! - parentLeft) / scaleX;
const finalTop = (rect.top! - parentTop) / scaleY;
const finalWidth = (rect.right! - rect.left!) / scaleX;
const finalHeight = (rect.bottom! - rect.top!) / scaleY;
this.element.style.transform = isBetweenFlatList ? `translateX(${-20}px` : `translateX(0px)`;
this.element.style.left = finalLeft + "px";
this.element.style.top = finalTop + "px";
this.element.style.width = finalWidth + "px";
this.element.style.height = finalHeight + "px";
}
scheduleRemoval(timeout: number) {
if (this.timeout) clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.setCursor(null), timeout);
}
dragover(event: DragEvent) {
if (!this.editorView.editable) return;
const pos = this.editorView.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!pos) return;
// Throttled call to the function
const result = this.isBetweenFlatListsFn(event);
let isHoveringOverListContentVar = false;
let isBetweenFlatListsVar = false;
if (result) {
if ("pos" in result) {
const { isBetweenFlatLists, pos: posList, isHoveringOverListContent } = result;
isBetweenFlatListsVar = isBetweenFlatLists;
isHoveringOverListContentVar = isHoveringOverListContent;
if (isBetweenFlatLists && this.element) {
pos.pos = posList;
}
} else {
const { isBetweenFlatLists, isHoveringOverListContent } = result;
isBetweenFlatListsVar = isBetweenFlatLists;
isHoveringOverListContentVar = isHoveringOverListContent;
}
}
const node = pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside);
const disableDropCursor = node && node.type.spec.disableDropCursor;
const disabled =
typeof disableDropCursor == "function" ? disableDropCursor(this.editorView, pos, event) : disableDropCursor;
if (pos && !disabled) {
let target = pos.pos;
if (this.editorView.dragging && this.editorView.dragging.slice) {
const point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice);
if (point != null) target = point;
}
this.dropPosByDropCursorPos = target;
this.setCursor(target, !!isBetweenFlatListsVar && !isHoveringOverListContentVar);
this.scheduleRemoval(5000);
}
}
dragend() {
this.scheduleRemoval(20);
}
drop() {
this.scheduleRemoval(20);
}
dragleave(event: DragEvent) {
const relatedTarget = event.relatedTarget as Node | null;
if (relatedTarget && !this.editorView.dom.contains(relatedTarget)) {
this.setCursor(null);
}
}
set dropPosByDropCursorPos(pos: number | null) {
const tr = this.editorView.state.tr;
tr.setMeta(this.pluginKey, { dropPosByDropCursorPos: pos });
this.editorView.dispatch(tr);
}
get dropPosByDropCursorPos(): number | null {
return this.pluginKey.getState(this.editorView.state)?.dropPosByDropCursorPos;
}
}
export const DropCursorExtension = Extension.create({
name: "dropCursor",
addProseMirrorPlugins() {
return [
dropCursor(
{
width: 2,
class: "transition-all duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
},
this
),
];
},
});
function findDirectChild(element: HTMLElement, parentClass: string) {
const parent = element.closest(`.${parentClass}`);
if (!parent) return null;
// Get all direct children of parent that contain our element
const directChildren = Array.from(parent.children);
return directChildren.find((child) => child.contains(element));
}
function rawIsBetweenFlatListsFn(event: DragEvent, editor: Editor) {
const coords = {
left: event.clientX,
top: event.clientY,
};
const positionCache = new WeakMap();
const elementUnderDrag = document.elementFromPoint(coords.left, coords.top);
if (!elementUnderDrag) return null;
const currentFlatList = elementUnderDrag.closest(".prosemirror-flat-list");
if (!currentFlatList) return null;
const currentListContent = currentFlatList.querySelector(".list-content");
const children = Array.from(currentListContent?.childNodes || []);
if (event.target instanceof HTMLElement) {
const child = findDirectChild(event.target as HTMLElement, "list-content");
const offset = children.indexOf(child as HTMLElement);
if (offset > 0) {
return { isBetweenFlatLists: false, isHoveringOverListContent: false };
}
}
let isInsideToggleOrTask = false;
if (
currentFlatList.getAttribute("data-list-kind") === "toggle" ||
currentFlatList.getAttribute("data-list-kind") === "task"
) {
isInsideToggleOrTask = true;
}
const state = {
isHoveringOverListContent: !elementUnderDrag.classList.contains("prosemirror-flat-list"),
isBetweenFlatLists: true,
hasNestedLists: false,
pos: null as number | null,
listLevel: 0,
isNestedList: false,
};
if (isInsideToggleOrTask) {
const firstChildListMarker = currentFlatList.firstChild as HTMLElement;
state.isHoveringOverListContent = firstChildListMarker?.classList.contains("list-marker");
}
const getPositionFromElement = (element: Element, some?: boolean): number | null => {
if (positionCache.has(element)) {
return positionCache.get(element);
}
const pos = editor.view.posAtDOM(element, 0);
function getNodeAtPos(state: EditorState, pos: number) {
const $pos = state.doc.resolve(pos);
return $pos.node();
}
const editorNode = getNodeAtPos(editor.view.state, pos);
let result = pos ?? null;
if (some) {
result = pos + editorNode.nodeSize;
}
positionCache.set(element, result);
return result;
};
// Check for child list within the current list item
const childList = currentFlatList?.querySelector(".prosemirror-flat-list");
if (childList) {
state.pos = getPositionFromElement(childList);
state.hasNestedLists = true;
state.isNestedList = true;
} else {
// Existing logic for other cases
const sibling = currentFlatList.nextElementSibling;
const firstNestedList = currentFlatList.querySelector(":scope > .prosemirror-flat-list");
const level = getListLevelOptimized(currentFlatList);
state.listLevel = level;
state.isNestedList = level >= 1;
if (sibling) {
state.pos = getPositionFromElement(sibling);
} else if (firstNestedList) {
state.pos = getPositionFromElement(firstNestedList);
state.hasNestedLists = true;
} else if (level >= 1 && !sibling) {
const parent = currentFlatList.parentElement?.parentElement;
const poss = getPositionFromElement(currentFlatList as Element, true);
if (parent) {
state.pos = poss;
}
}
}
if (!state.pos) return null;
return {
...state,
pos: state.pos - 1,
};
}
// Optimized list level calculation
function getListLevelOptimized(element: Element): number {
let level = 0;
let current = element.parentElement;
// Use a more efficient selector matching
while (current && !current.classList.contains("ProseMirror")) {
if (current.classList.contains("prosemirror-flat-list")) {
level++;
}
current = current.parentElement;
}
return level;
}
function createThrottledIsBetweenFlatListsFn(
editor: Editor,
moveThreshold = 8 // px of mouse movement before re-checking
) {
let lastX = 0;
let lastY = 0;
let lastResult: ReturnType<typeof rawIsBetweenFlatListsFn> | null = null;
return function throttledIsBetweenFlatListsFn(event: DragEvent) {
const dx = Math.abs(event.clientX - lastX);
const dy = Math.abs(event.clientY - lastY);
// Only recalc if we moved enough OR enough time passed
if (dx < moveThreshold && dy < moveThreshold) {
return lastResult;
}
lastX = event.clientX;
lastY = event.clientY;
lastResult = rawIsBetweenFlatListsFn(event, editor);
return lastResult;
};
}
+52 -137
View File
@@ -1,11 +1,8 @@
import { Extensions } from "@tiptap/core";
// import BulletList from "@tiptap/extension-bullet-list";
import CharacterCount from "@tiptap/extension-character-count";
// import ListItem from "@tiptap/extension-list-item";
// import OrderedList from "@tiptap/extension-ordered-list";
import Placeholder from "@tiptap/extension-placeholder";
// import TaskItem from "@tiptap/extension-task-item";
// import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
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";
@@ -19,6 +16,7 @@ import {
CustomCodeInlineExtension,
CustomColorExtension,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMentionExtension,
@@ -26,13 +24,12 @@ import {
CustomTextAlignExtension,
CustomTypographyExtension,
ImageExtension,
ListKeymap,
Table,
TableCell,
TableHeader,
TableRow,
FlatListExtension,
UtilityExtension,
DropCursorExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -41,8 +38,6 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type { IEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type TArguments = Pick<
IEditorProps,
@@ -53,14 +48,34 @@ type TArguments = Pick<
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, flaggedExtensions, mentionHandler, placeholder, editable } =
args;
const {
disabledExtensions,
enableHistory,
fileHandler,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
editable,
} = args;
const extensions = [
StarterKit.configure({
bulletList: false,
orderedList: false,
listItem: false,
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-[--list-spacing-y]",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
code: false,
codeBlock: false,
horizontalRule: false,
@@ -75,78 +90,12 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
class: "editor-heading-block",
},
},
// dropcursor: {
// class: "text-custom-text-300",
// },
dropcursor: false,
dropcursor: {
class:
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
},
...(enableHistory ? {} : { history: false }),
}),
DropCursorExtension,
FlatListExtension,
// BulletList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "list-disc pl-7 space-y-2",
// },
// }),
// OrderedList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "list-decimal pl-7 space-y-2",
// },
// }),
// ListItem.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "not-prose space-y-2",
// },
// }),
// TaskList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "not-prose pl-2 space-y-2",
// },
// }),
// TaskItem.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// addKeyboardShortcuts() {
// return {};
// },
// }).configure({
// HTMLAttributes: {
// class: "relative",
// },
// nested: true,
// }),
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
@@ -154,7 +103,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
}),
CustomKeymap,
// ListKeymap({ tabIndex }),
ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
@@ -169,58 +118,24 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomTypographyExtension,
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeInlineExtension,
Markdown.extend({
addMarkdownSerializerRules() {
return {
list: (
state: {
write: (text: string) => void;
ensureNewLine: () => void;
serializeFragment: (fragment: any) => string;
},
node: { attrs: Record<string, any>; content: any }
) => {
// Custom serializer for flat-list nodes
const attrs = node.attrs as { kind?: string; order?: number; checked?: boolean; collapsed?: boolean };
const listKind = attrs.kind || "bullet";
const isChecked = attrs.checked;
const isCollapsed = attrs.collapsed;
// Serialize the content of this list item
const content = state.serializeFragment(node.content);
// Create the appropriate markdown based on list type
switch (listKind) {
case "task":
state.write(`- [${isChecked ? "x" : " "}] ${content}`);
break;
case "toggle": {
const togglePrefix = isCollapsed ? "▶" : "▼";
state.write(`- ${togglePrefix} ${content}`);
break;
}
case "ordered": {
const orderNum = attrs.order || 1;
state.write(`${orderNum}. ${content}`);
break;
}
case "bullet":
default:
state.write(`- ${content}`);
break;
}
state.ensureNewLine();
},
};
},
}).configure({
Markdown.configure({
html: true,
transformCopiedText: false,
transformPastedText: true,
@@ -276,15 +191,15 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension({
fileHandler,
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension({
fileHandler,
isEditable: editable,
})
CustomImageExtension(fileHandler)
);
}
// @ts-expect-error tiptap types are incorrect
return extensions;
};

Some files were not shown because too many files have changed in this diff Show More