Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eb5b4f150 | ||
|
|
c063eaeedb | ||
|
|
24a3c08758 | ||
|
|
829fc4752f | ||
|
|
18a3771315 | ||
|
|
31c09d5a48 | ||
|
|
1315acf952 | ||
|
|
728f517cb1 | ||
|
|
cb3b6fbd8d | ||
|
|
70dbad648d | ||
|
|
ad9888ce45 | ||
|
|
4eef115fcd | ||
|
|
cabd5f4275 | ||
|
|
7cbdcd4c94 | ||
|
|
cef60b55e4 | ||
|
|
07bf68c8ca | ||
|
|
3d07d0f678 | ||
|
|
6c2fb4b287 | ||
|
|
c1b8feaf6f | ||
|
|
8a9cdc6133 | ||
|
|
6db75fe29c | ||
|
|
ea7ebe66b1 | ||
|
|
93066ef5d5 | ||
|
|
2db9d35678 | ||
|
|
37cd01e306 | ||
|
|
d3defc9785 | ||
|
|
3a0891e0ee | ||
|
|
53efad3399 | ||
|
|
304ef1a80c | ||
|
|
16d41a3841 | ||
|
|
a9f4427b21 | ||
|
|
e4f31aea08 | ||
|
|
c2a3e47d3d | ||
|
|
cef4110eb0 | ||
|
|
3672ee4ef1 | ||
|
|
c56097b8c0 | ||
|
|
0d57e0ab32 | ||
|
|
df35ccecc9 | ||
|
|
38d8d3ea9b | ||
|
|
3710b182d3 | ||
|
|
6897575a62 | ||
|
|
388151b70b | ||
|
|
3d61604569 | ||
|
|
1b29f65664 | ||
|
|
a229508611 | ||
|
|
d5bd4ef63a | ||
|
|
146332fff3 | ||
|
|
5802858772 | ||
|
|
b39ce9c18a | ||
|
|
a7ab5ae680 | ||
|
|
f2a08853e2 | ||
|
|
23eeb45713 | ||
|
|
6c83a0df09 | ||
|
|
dbee7488e1 |
@@ -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
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { registerProjectPageHandler } from "./project-page/project-page-handler";
|
||||
|
||||
export function initializeDocumentHandlers() {
|
||||
registerProjectPageHandler();
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
Vendored
+28
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
}
|
||||
)();
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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())])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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();
|
||||
Vendored
+6
-1
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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 +0,0 @@
|
||||
export * from "../../ce/lib/authentication.js"
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../ce/lib/fetch-document.js"
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../ce/lib/update-document.js"
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
export * from "../../ce/types/common.js"
|
||||
@@ -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();
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
+53
-27
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
+25
-22
@@ -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>
|
||||
+11
-13
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user